diff --git a/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py new file mode 100644 index 00000000..4ae3047b --- /dev/null +++ b/backend/alembic/versions/d3e4f5a6b7c8_tournament_events_backend_rewrite.py @@ -0,0 +1,202 @@ +"""tournament events backend rewrite + +Revision ID: d3e4f5a6b7c8 +Revises: c7b3f4a9d2e1 +Create Date: 2026-08-13 00:00:00.000000 + +Single migration for the tournament-events backend rewrite, built up +incrementally as the rewrite progresses: + + - tournament_memberships: drop `assigned_event_id` column (and its FK to + tournament_events) + - tournament_memberships: drop `schedule` column + No backfill — this data is not being preserved or migrated anywhere. + + - tournament_shifts: new table — tournament-scoped time windows + - tournament_event_shifts: new bridge table — TournamentEvent <-> TournamentShift + + - tournament_events: drop `blocks` and `category` columns (no migration + path — old free-text categories don't reliably match canonical + category names, and the old block-scheduling system is gone, not + migrated to shifts) + - tournament_events: `division` and `name` made nullable (`name` is now + custom-event-only display text — catalog-linked events display the + joined Event.name instead) + - tournament_events: add `event_id` FK to events (SET NULL), nullable + - tournament_events: add `start_time`/`end_time`, both nullable. No backfill for + existing rows; they simply stay NULL. Frontend is expected to warn on + unset times, not block on them. + - tournament_events: drop `uq_tournament_event_division`, add a partial + unique index on (tournament_id, event_id, division) WHERE event_id IS + NOT NULL — custom (event_id-less) events have no uniqueness constraint + - tournament_events: `volunteers_needed` made nullable, default dropped + (no more implicit "2" — unset means genuinely unset, not a guess) + + - season_events: new table — admin-curated per-year/division active + event list, drives the tournament events bulk-load default list. No + backfill, starts empty. + + - tournaments: add `timezone` (IANA name), NOT NULL. Set once at + creation from the creator's browser timezone, immutable after — + no update path. Existing rows backfilled to "America/Los_Angeles". +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + + +revision: str = "d3e4f5a6b7c8" +down_revision: Union[str, None] = "c7b3f4a9d2e1" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ------------------------------------------------------------------ + # tournament_memberships — drop assigned_event_id + schedule + # ------------------------------------------------------------------ + op.drop_constraint( + "memberships_assigned_event_id_fkey", + "tournament_memberships", + type_="foreignkey", + ) + op.drop_column("tournament_memberships", "assigned_event_id") + op.drop_column("tournament_memberships", "schedule") + + # ------------------------------------------------------------------ + # tournament_shifts + tournament_event_shifts — new tables + # ------------------------------------------------------------------ + op.create_table( + "tournament_shifts", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("tournament_id", sa.Integer(), sa.ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False), + sa.Column("label", sa.String(length=255), nullable=False), + sa.Column("start", sa.DateTime(timezone=True), nullable=False), + sa.Column("end", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + ) + op.create_table( + "tournament_event_shifts", + sa.Column("tournament_event_id", sa.Integer(), sa.ForeignKey("tournament_events.id", ondelete="CASCADE"), primary_key=True), + sa.Column("tournament_shift_id", sa.Integer(), sa.ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True), + ) + + # ------------------------------------------------------------------ + # tournament_events — drop blocks/category, add event_id, add + # start_time/end_time (nullable, no backfill), relax division, swap + # the unique constraint for a partial unique index scoped to + # catalog-linked rows + # ------------------------------------------------------------------ + op.drop_constraint("uq_tournament_event_division", "tournament_events", type_="unique") + op.alter_column("tournament_events", "division", existing_type=sa.String(length=4), nullable=True) + op.alter_column("tournament_events", "name", existing_type=sa.String(length=255), nullable=True) + + op.drop_column("tournament_events", "blocks") + op.drop_column("tournament_events", "category") + + op.add_column( + "tournament_events", + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="SET NULL"), nullable=True), + ) + op.add_column("tournament_events", sa.Column("start_time", sa.DateTime(timezone=True), nullable=True)) + op.add_column("tournament_events", sa.Column("end_time", sa.DateTime(timezone=True), nullable=True)) + + op.create_index( + "uq_tournament_event_catalog_division", + "tournament_events", + ["tournament_id", "event_id", "division"], + unique=True, + postgresql_where=text("event_id IS NOT NULL"), + ) + + op.alter_column("tournament_events", "volunteers_needed", existing_type=sa.Integer(), nullable=True) + + # ------------------------------------------------------------------ + # season_events — new table + # ------------------------------------------------------------------ + op.create_table( + "season_events", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("event_id", sa.Integer(), sa.ForeignKey("events.id", ondelete="CASCADE"), nullable=False), + sa.Column("year", sa.Integer(), nullable=False), + sa.Column("division", sa.String(length=4), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("event_id", "year", "division", name="uq_season_event"), + ) + + # ------------------------------------------------------------------ + # tournaments — add timezone (backfilled, then locked to NOT NULL) + # ------------------------------------------------------------------ + op.add_column( + "tournaments", + sa.Column("timezone", sa.String(length=64), nullable=False, server_default="America/Los_Angeles"), + ) + op.alter_column("tournaments", "timezone", server_default=None) + + +def downgrade() -> None: + # ------------------------------------------------------------------ + # tournaments — drop timezone + # ------------------------------------------------------------------ + op.drop_column("tournaments", "timezone") + + # ------------------------------------------------------------------ + # season_events — drop new table + # ------------------------------------------------------------------ + op.drop_table("season_events") + + # ------------------------------------------------------------------ + # tournament_events — restore blocks/category/old unique constraint, + # drop event_id/start_time/end_time + # NOTE: data will be lost on downgrade (event_id links, start/end times). + # ------------------------------------------------------------------ + op.drop_index("uq_tournament_event_catalog_division", table_name="tournament_events") + + # Any rows with NULL volunteers_needed get 2 (the old default) so the + # NOT NULL constraint below doesn't fail. + op.execute("UPDATE tournament_events SET volunteers_needed = 2 WHERE volunteers_needed IS NULL") + op.alter_column("tournament_events", "volunteers_needed", existing_type=sa.Integer(), nullable=False) + + op.drop_column("tournament_events", "end_time") + op.drop_column("tournament_events", "start_time") + op.drop_column("tournament_events", "event_id") + + op.add_column("tournament_events", sa.Column("category", sa.String(length=255), nullable=True)) + op.add_column("tournament_events", sa.Column("blocks", sa.JSON(), nullable=False, server_default="[]")) + op.alter_column("tournament_events", "blocks", server_default=None) + + op.alter_column("tournament_events", "division", existing_type=sa.String(length=4), nullable=False) + op.alter_column("tournament_events", "name", existing_type=sa.String(length=255), nullable=False) + op.create_unique_constraint( + "uq_tournament_event_division", "tournament_events", ["tournament_id", "name", "division"] + ) + + # ------------------------------------------------------------------ + # tournament_shifts + tournament_event_shifts — drop new tables + # ------------------------------------------------------------------ + op.drop_table("tournament_event_shifts") + op.drop_table("tournament_shifts") + + # ------------------------------------------------------------------ + # tournament_memberships — restore assigned_event_id + schedule + # NOTE: data will be lost on downgrade. + # ------------------------------------------------------------------ + op.add_column( + "tournament_memberships", + sa.Column("schedule", sa.JSON(), nullable=True), + ) + op.add_column( + "tournament_memberships", + sa.Column("assigned_event_id", sa.Integer(), nullable=True), + ) + op.create_foreign_key( + "memberships_assigned_event_id_fkey", + "tournament_memberships", + "tournament_events", + ["assigned_event_id"], + ["id"], + ondelete="SET NULL", + ) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index b567e581..4b0a2474 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -12,6 +12,11 @@ router = APIRouter(tags=["events"]) +# Admin-only writes live under /admin/, matching app/api/routes/tournament/admin.py +# and app/api/routes/season_event.py — the public GET routes above stay +# unprefixed and unauthenticated. +admin_router = APIRouter(prefix="/admin", tags=["events"]) + # --------------------------------------------------------------------------- # GET /events/ — list all events @@ -22,9 +27,9 @@ def list_events(db: Session = Depends(get_db)): # --------------------------------------------------------------------------- -# POST /events/ — admin only, create a new event +# POST /admin/events/ — admin only, create a new event # --------------------------------------------------------------------------- -@router.post("/events/", response_model=EventResponse, status_code=status.HTTP_201_CREATED) +@admin_router.post("/events/", response_model=EventResponse, status_code=status.HTTP_201_CREATED) def create_event(body: EventCreate, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = db.get(EventCategory, body.category_id) if not category: @@ -38,9 +43,9 @@ def create_event(body: EventCreate, db: Session = Depends(get_db), _: User = Dep # --------------------------------------------------------------------------- -# PATCH /events/{id}/ — admin only, partial update +# PATCH /admin/events/{id}/ — admin only, partial update # --------------------------------------------------------------------------- -@router.patch("/events/{event_id}/", response_model=EventResponse) +@admin_router.patch("/events/{event_id}/", response_model=EventResponse) def update_event(event_id: int, body: EventUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin)): event = db.get(Event, event_id) if not event: @@ -62,9 +67,9 @@ def update_event(event_id: int, body: EventUpdate, db: Session = Depends(get_db) # --------------------------------------------------------------------------- -# DELETE /events/{id}/ — admin only, hard delete blocked if experience entries exist +# DELETE /admin/events/{id}/ — admin only, hard delete blocked if experience entries exist # --------------------------------------------------------------------------- -@router.delete("/events/{event_id}/", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete("/events/{event_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_event(event_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)): event = db.get(Event, event_id) if not event: @@ -90,9 +95,9 @@ def list_event_categories(db: Session = Depends(get_db)): # --------------------------------------------------------------------------- -# POST /event-categories/ — admin only, create a new category +# POST /admin/event-categories/ — admin only, create a new category # --------------------------------------------------------------------------- -@router.post("/event-categories/", response_model=EventCategoryResponse, status_code=status.HTTP_201_CREATED) +@admin_router.post("/event-categories/", response_model=EventCategoryResponse, status_code=status.HTTP_201_CREATED) def create_event_category(body: EventCategoryCreate, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = EventCategory(name=body.name) db.add(category) @@ -102,9 +107,9 @@ def create_event_category(body: EventCategoryCreate, db: Session = Depends(get_d # --------------------------------------------------------------------------- -# PATCH /event-categories/{id}/ — admin only, partial update +# PATCH /admin/event-categories/{id}/ — admin only, partial update # --------------------------------------------------------------------------- -@router.patch("/event-categories/{category_id}/", response_model=EventCategoryResponse) +@admin_router.patch("/event-categories/{category_id}/", response_model=EventCategoryResponse) def update_event_category( category_id: int, body: EventCategoryUpdate, @@ -124,10 +129,10 @@ def update_event_category( # --------------------------------------------------------------------------- -# DELETE /event-categories/{id}/ — admin only, cascades to delete its events +# DELETE /admin/event-categories/{id}/ — admin only, cascades to delete its events # (blocked if any of those events has experience entries — see delete_event note) # --------------------------------------------------------------------------- -@router.delete("/event-categories/{category_id}/", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete("/event-categories/{category_id}/", status_code=status.HTTP_204_NO_CONTENT) def delete_event_category(category_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin)): category = db.get(EventCategory, category_id) if not category: @@ -141,4 +146,4 @@ def delete_event_category(category_id: int, db: Session = Depends(get_db), _: Us raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Cannot delete category: one or more of its events has associated experience entries", - ) \ No newline at end of file + ) diff --git a/backend/app/api/routes/season_event.py b/backend/app/api/routes/season_event.py new file mode 100644 index 00000000..62006359 --- /dev/null +++ b/backend/app/api/routes/season_event.py @@ -0,0 +1,101 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload + +from app.core.auth import require_admin +from app.db.session import get_db +from app.models.models import SeasonEvent, User +from app.schemas.season_event import SeasonEventCreate, SeasonEventRead, SeasonEventUpdate + +# GET is public/unauthenticated, matching the canonical Event/EventCategory +# split (app/api/routes/events.py) — writes are admin-only, under /admin/. +router = APIRouter(prefix="/season-events", tags=["season-events"]) +admin_router = APIRouter(prefix="/admin/season-events", tags=["season-events"]) + + +# --------------------------------------------------------------------------- +# GET /season-events/ — filterable by year, and by one or more divisions +# (repeat the query param, e.g. ?division=B&division=C) +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[SeasonEventRead]) +def list_season_events( + year: int | None = Query(None), + division: list[str] | None = Query(None), + db: Session = Depends(get_db), +): + query = db.query(SeasonEvent).options(joinedload(SeasonEvent.event)) + if year is not None: + query = query.filter(SeasonEvent.year == year) + if division: + query = query.filter(SeasonEvent.division.in_(division)) + return query.order_by(SeasonEvent.year.desc(), SeasonEvent.division).all() + + +# --------------------------------------------------------------------------- +# POST /admin/season-events/ — admin only +# --------------------------------------------------------------------------- +@admin_router.post("/", response_model=SeasonEventRead, status_code=status.HTTP_201_CREATED) +def create_season_event( + payload: SeasonEventCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = SeasonEvent(**payload.model_dump()) + db.add(season_event) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A season event for this event/year/division already exists", + ) + db.refresh(season_event) + return season_event + + +# --------------------------------------------------------------------------- +# PATCH /admin/season-events/{id}/ — admin only, primarily used to toggle is_active +# --------------------------------------------------------------------------- +@admin_router.patch("/{season_event_id}/", response_model=SeasonEventRead) +def update_season_event( + season_event_id: int, + payload: SeasonEventUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = db.get(SeasonEvent, season_event_id) + if not season_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season event not found") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(season_event, field, value) + + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A season event for this event/year/division already exists", + ) + db.refresh(season_event) + return season_event + + +# --------------------------------------------------------------------------- +# DELETE /admin/season-events/{id}/ — admin only +# --------------------------------------------------------------------------- +@admin_router.delete("/{season_event_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_season_event( + season_event_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +): + season_event = db.get(SeasonEvent, season_event_id) + if not season_event: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Season event not found") + + db.delete(season_event) + db.commit() diff --git a/backend/app/api/routes/tournament/__init__.py b/backend/app/api/routes/tournament/__init__.py index 81575d0a..0f3967b5 100644 --- a/backend/app/api/routes/tournament/__init__.py +++ b/backend/app/api/routes/tournament/__init__.py @@ -20,7 +20,7 @@ from app.db.session import get_db from app.models.models import Tournament, TournamentMembership, User from app.schemas.tournament import ( - TournamentCreate, TournamentRead, TournamentUpdate, TransferOwnershipRequest, + TournamentCreate, TournamentRead, TournamentSummary, TournamentUpdate, TransferOwnershipRequest, ) router = APIRouter(prefix="/tournaments", tags=["tournaments"]) @@ -51,7 +51,7 @@ def _serialize(tournament: Tournament) -> dict: # --------------------------------------------------------------------------- # GET /tournaments/me — tournaments the current user has any membership in. # --------------------------------------------------------------------------- -@router.get("/me/", response_model=list[TournamentRead]) +@router.get("/me/", response_model=list[TournamentSummary]) def list_my_tournaments( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -64,7 +64,14 @@ def list_my_tournaments( .order_by(Tournament.created_at.desc()) .all() ) - return [_serialize(t) for t in tournaments] + return [ + TournamentSummary( + **_serialize(t), + event_count=len(t.events), + volunteer_count=len(t.memberships), + ) + for t in tournaments + ] # --------------------------------------------------------------------------- diff --git a/backend/app/api/routes/tournament/events.py b/backend/app/api/routes/tournament/events.py index cd1d6c39..bf4c551b 100644 --- a/backend/app/api/routes/tournament/events.py +++ b/backend/app/api/routes/tournament/events.py @@ -1,34 +1,47 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy.orm import Session +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload -from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived +from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived, tournament_local_date from app.core.tournament.permissions import MANAGE_EVENTS, require_permission from app.db.session import get_db -from app.models.models import TournamentEvent, User -from app.schemas.tournament.event import EventCreate, EventRead, EventUpdate +from app.models.models import SeasonEvent, TournamentEvent, TournamentShift, User +from app.schemas.tournament.event import ( + EventCreate, EventLoadDefaultsResponse, EventLoadDefaultsSkipped, EventRead, EventUpdate, +) # Routes are nested: /tournaments/{tournament_id}/events/... # tournament_id is always present in the path, which drives the permission check. router = APIRouter(prefix="/tournaments/{tournament_id}/events", tags=["tournaments"]) -def _serialize(event: TournamentEvent) -> dict: - return { - "id": event.id, - "tournament_id": event.tournament_id, - "name": event.name, - "division": event.division, - "event_type": event.event_type, - "category": event.category, - "building": event.building, - "room": event.room, - "floor": event.floor, - "volunteers_needed": event.volunteers_needed, - "blocks": event.blocks or [], - "created_at": event.created_at, - "updated_at": event.updated_at, - } +def _validate_division(division: str | None, tournament) -> None: + """A set division must be one of the divisions the tournament itself + supports. SeasonEvent plays no role here — it's independent of what's + "suggested" for the tournament.""" + if division is not None and division not in (tournament.division or []): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"division must be one of the tournament's divisions: {tournament.division}", + ) + + +def _validate_tournament_bounds(event: TournamentEvent, tournament) -> None: + """start_time/end_time are nullable (planning starts before per-event + times are known), so only bound whichever ones are set. Compared in the + tournament's own timezone — start_date/end_date are naive local dates, + not UTC ones.""" + if event.start_time is not None and tournament_local_date(tournament, event.start_time) < tournament.start_date: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Event start_time falls before the tournament's start_date", + ) + if event.end_time is not None and tournament_local_date(tournament, event.end_time) > tournament.end_date: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Event end_time falls after the tournament's end_date", + ) # --------------------------------------------------------------------------- @@ -45,11 +58,15 @@ def list_events( events = ( db.query(TournamentEvent) + .options( + joinedload(TournamentEvent.event), + joinedload(TournamentEvent.shifts).joinedload(TournamentShift.tournament_events), + ) .filter(TournamentEvent.tournament_id == tournament_id) .order_by(TournamentEvent.division, TournamentEvent.name) .all() ) - return [_serialize(e) for e in events] + return events # --------------------------------------------------------------------------- @@ -62,7 +79,7 @@ def get_event( db: Session = Depends(get_db), current_user: User = Depends(require_permission(MANAGE_EVENTS)), ): - return _serialize(get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event")) + return get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") # --------------------------------------------------------------------------- @@ -85,22 +102,21 @@ def create_event( detail="tournament_id in body does not match URL", ) - existing = db.query(TournamentEvent).filter( - TournamentEvent.tournament_id == tournament_id, - TournamentEvent.name == payload.name, - TournamentEvent.division == payload.division, - ).first() - if existing: - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail=f"Event '{payload.name}' division {payload.division} already exists in this tournament", - ) + _validate_division(payload.division, tournament) event = TournamentEvent(**payload.model_dump()) + _validate_tournament_bounds(event, tournament) db.add(event) - db.commit() + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This catalog event already exists in this tournament for this division", + ) db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -119,12 +135,31 @@ def update_event( event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") - for field, value in payload.model_dump(exclude_none=True).items(): + update_data = payload.model_dump(exclude_unset=True) + if "division" in update_data: + _validate_division(update_data["division"], tournament) + + for field, value in update_data.items(): setattr(event, field, value) - db.commit() + _validate_tournament_bounds(event, tournament) + + if event.name is None and event.event_id is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Cannot clear both name and event_id — at least one must be set", + ) + + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This catalog event already exists in this tournament for this division", + ) db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -142,4 +177,67 @@ def delete_event( event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") db.delete(event) - db.commit() \ No newline at end of file + db.commit() + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/events/load-defaults/ — manage_events +# Bulk-creates TournamentEvent rows from every active SeasonEvent whose +# division the tournament supports. +# --------------------------------------------------------------------------- +@router.post("/load-defaults/", response_model=EventLoadDefaultsResponse, status_code=status.HTTP_201_CREATED) +def load_default_events( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + active_season_events = ( + db.query(SeasonEvent) + .options(joinedload(SeasonEvent.event)) + .filter( + SeasonEvent.is_active.is_(True), + SeasonEvent.division.in_(tournament.division or []), + ) + .all() + ) + + existing_pairs = { + (te.event_id, te.division) + for te in db.query(TournamentEvent).filter( + TournamentEvent.tournament_id == tournament_id, + TournamentEvent.event_id.isnot(None), + ) + } + + created: list[TournamentEvent] = [] + skipped: list[EventLoadDefaultsSkipped] = [] + seen_pairs: set[tuple[int, str]] = set() + + for season_event in active_season_events: + key = (season_event.event_id, season_event.division) + if key in existing_pairs or key in seen_pairs: + skipped.append(EventLoadDefaultsSkipped( + event_id=season_event.event_id, + division=season_event.division, + name=season_event.event.name, + )) + continue + seen_pairs.add(key) + + event = TournamentEvent( + tournament_id=tournament_id, + event_id=season_event.event_id, + division=season_event.division, + name=season_event.event.name, + ) + db.add(event) + created.append(event) + + db.commit() + for event in created: + db.refresh(event) + + return EventLoadDefaultsResponse(created=created, skipped=skipped) diff --git a/backend/app/api/routes/tournament/memberships.py b/backend/app/api/routes/tournament/memberships.py index a569b0b2..43231b6c 100644 --- a/backend/app/api/routes/tournament/memberships.py +++ b/backend/app/api/routes/tournament/memberships.py @@ -195,7 +195,7 @@ def get_membership( # --------------------------------------------------------------------------- # PATCH /tournaments/{tournament_id}/memberships/me/ — self-service # Lets a volunteer update their own onboarding responses. Cannot touch -# day-of logistics (schedule, notes) — that's manage_members-only. +# day-of logistics (notes) — that's manage_members-only. # --------------------------------------------------------------------------- @router.patch("/me/", response_model=MembershipFullResponse) def update_my_membership( @@ -225,7 +225,7 @@ def update_my_membership( # --------------------------------------------------------------------------- # PATCH /tournaments/{tournament_id}/memberships/{membership_id} — manage_members (rank-bound) -# Staff override — day-of logistics only (schedule, notes). Not onboarding +# Staff override — day-of logistics only (notes). Not onboarding # data; that's self-service via PATCH .../me/. # --------------------------------------------------------------------------- @router.patch("/{membership_id}/", response_model=MembershipFullResponse) @@ -243,8 +243,6 @@ def update_membership( validate_member_target(current_user, tournament, m, db) update_data = payload.model_dump(exclude_none=True) - if "schedule" in update_data and payload.schedule: - update_data["schedule"] = [s.model_dump() for s in payload.schedule] for field, value in update_data.items(): setattr(m, field, value) diff --git a/backend/app/api/routes/tournament/shifts.py b/backend/app/api/routes/tournament/shifts.py new file mode 100644 index 00000000..3e34be58 --- /dev/null +++ b/backend/app/api/routes/tournament/shifts.py @@ -0,0 +1,227 @@ +from __future__ import annotations +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, selectinload + +from app.core.tournament import get_scoped_or_404, get_tournament, require_not_archived, tournament_local_date +from app.core.tournament.permissions import MANAGE_EVENTS, require_permission +from app.db.session import get_db +from app.models.models import TournamentEvent, TournamentEventShift, TournamentShift, User +from app.schemas.tournament.shift import TournamentShiftCreate, TournamentShiftRead, TournamentShiftUpdate + +# Routes are nested: /tournaments/{tournament_id}/shifts/... +router = APIRouter(prefix="/tournaments/{tournament_id}/shifts", tags=["tournaments"]) + + +# --------------------------------------------------------------------------- +# GET /tournaments/{tournament_id}/shifts/ — manage_events +# --------------------------------------------------------------------------- +@router.get("/", response_model=list[TournamentShiftRead]) +def list_shifts( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + get_tournament(tournament_id, db) + return ( + db.query(TournamentShift) + .options(selectinload(TournamentShift.tournament_events)) + .filter(TournamentShift.tournament_id == tournament_id) + .order_by(TournamentShift.start) + .all() + ) + + +def _validate_tournament_bounds(shift: TournamentShift, tournament) -> None: + """Compared in the tournament's own timezone — start_date/end_date are + naive local dates, not UTC ones.""" + if tournament_local_date(tournament, shift.start) < tournament.start_date: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Shift start falls before the tournament's start_date", + ) + if tournament_local_date(tournament, shift.end) > tournament.end_date: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Shift end falls after the tournament's end_date", + ) + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/shifts/ — manage_events +# --------------------------------------------------------------------------- +@router.post("/", response_model=TournamentShiftRead, status_code=status.HTTP_201_CREATED) +def create_shift( + tournament_id: int, + payload: TournamentShiftCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = TournamentShift(tournament_id=tournament_id, **payload.model_dump()) + _validate_tournament_bounds(shift, tournament) + db.add(shift) + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@router.patch("/{shift_id}/", response_model=TournamentShiftRead) +def update_shift( + tournament_id: int, + shift_id: int, + payload: TournamentShiftUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(shift, field, value) + + _validate_tournament_bounds(shift, tournament) + + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/shifts/{shift_id}/ — manage_events +# No guard — cascades through tournament_event_shifts (ondelete="CASCADE"), +# silently detaching from any events it was attached to. Intentionally +# different from how TimeBlock deletion worked in the old scrapped design. +# --------------------------------------------------------------------------- +@router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_shift( + tournament_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + db.delete(shift) + db.commit() + + +# --------------------------------------------------------------------------- +# Shift attachment — a sub-resource of events, so nested under +# /tournaments/{tournament_id}/events/{event_id}/shifts/. Single attach/detach +# endpoints per shift (not a batch add/remove like membership roles) because +# each attach carries its own bounds/overlap validation with a specific 409 +# reason — a batch call would need per-item partial failure reporting that +# isn't worth the complexity here. +# --------------------------------------------------------------------------- +event_shifts_router = APIRouter( + prefix="/tournaments/{tournament_id}/events/{event_id}/shifts", + tags=["tournaments"], +) + + +def _validate_attach(event: TournamentEvent, shift: TournamentShift, db: Session) -> None: + if event.start_time is None or event.end_time is None: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Event must have both a start_time and end_time before shifts can be attached", + ) + + if shift.start < event.start_time or shift.end > event.end_time: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Shift must fall entirely within the event's start/end time", + ) + + attached = ( + db.query(TournamentShift) + .join(TournamentEventShift, TournamentEventShift.tournament_shift_id == TournamentShift.id) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentShift.id != shift.id, + ) + .all() + ) + for other in attached: + # Adjacent (end == start) is fine — only strict overlap is rejected. + if shift.start < other.end and shift.end > other.start: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"Shift overlaps another shift already attached to this event ('{other.label}')", + ) + + +# --------------------------------------------------------------------------- +# POST /tournaments/{tournament_id}/events/{event_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@event_shifts_router.post("/{shift_id}/", response_model=TournamentShiftRead, status_code=status.HTTP_201_CREATED) +def attach_shift( + tournament_id: int, + event_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + already_attached = ( + db.query(TournamentEventShift) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentEventShift.tournament_shift_id == shift.id, + ) + .first() + ) + if already_attached: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Shift already attached to this event") + + _validate_attach(event, shift, db) + + db.add(TournamentEventShift(tournament_event_id=event.id, tournament_shift_id=shift.id)) + db.commit() + db.refresh(shift) + return shift + + +# --------------------------------------------------------------------------- +# DELETE /tournaments/{tournament_id}/events/{event_id}/shifts/{shift_id}/ — manage_events +# --------------------------------------------------------------------------- +@event_shifts_router.delete("/{shift_id}/", status_code=status.HTTP_204_NO_CONTENT) +def detach_shift( + tournament_id: int, + event_id: int, + shift_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + tournament = get_tournament(tournament_id, db) + require_not_archived(tournament) + + event = get_scoped_or_404(db, TournamentEvent, event_id, tournament_id, "Event") + shift = get_scoped_or_404(db, TournamentShift, shift_id, tournament_id, "Shift") + + bridge = ( + db.query(TournamentEventShift) + .filter( + TournamentEventShift.tournament_event_id == event.id, + TournamentEventShift.tournament_shift_id == shift.id, + ) + .first() + ) + if not bridge: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shift not attached to this event") + + db.delete(bridge) + db.commit() diff --git a/backend/app/core/tournament/__init__.py b/backend/app/core/tournament/__init__.py index ad01bc20..e4e653f4 100644 --- a/backend/app/core/tournament/__init__.py +++ b/backend/app/core/tournament/__init__.py @@ -1,5 +1,7 @@ from __future__ import annotations +from datetime import datetime from typing import TypeVar +from zoneinfo import ZoneInfo from fastapi import Depends, HTTPException, status from sqlalchemy.orm import Session @@ -10,6 +12,16 @@ T = TypeVar("T") +def tournament_local_date(tournament: Tournament, moment: datetime): + """Convert a tz-aware UTC instant to the calendar date it falls on in + the tournament's own timezone — start_date/end_date are naive dates + with no timezone of their own, so any comparison against them needs to + go through the tournament's timezone first, not a bare `.date()` on + the UTC value (which drifts a day off near midnight for any tz other + than UTC).""" + return moment.astimezone(ZoneInfo(tournament.timezone)).date() + + def get_scoped_or_404(db: Session, model: type[T], id_: int, tournament_id: int, label: str) -> T: """ Fetch a row by id, scoped to tournament_id via its own tournament_id diff --git a/backend/app/core/tournament/roles.py b/backend/app/core/tournament/roles.py index ee7b2d38..2c65cfbc 100644 --- a/backend/app/core/tournament/roles.py +++ b/backend/app/core/tournament/roles.py @@ -81,15 +81,17 @@ def validate_member_target(actor: "User", tournament: "Tournament", membership: Guards actions taken on a member's own row — removing them from the tournament, editing their day-of logistics, or (via validate_role_action) whether their role assignments can be touched at all. This is about who - can be a target, not which role is involved. Owner and platform admins - bypass entirely. + can be a target, not which role is involved. The tournament owner's membership can never be a target for anyone else, - full stop — even an actor with no rank of their own to compare against. - Rank is opt-in (the owner isn't required to hold a TournamentRole), so - without this explicit check an unranked owner would be unprotected by - the rank comparison below: get_highest_rank would return None for them, - and "target_rank is not None and ..." silently passes on None. + full stop — not even a platform admin, and even an actor with no rank of + their own to compare against. This check runs before the owner/admin + bypass below on purpose: platform admins are exempt from rank checks but + not from the "don't touch the owner" rule. Rank is opt-in (the owner + isn't required to hold a TournamentRole), so without this explicit check + an unranked owner would be unprotected by the rank comparison below: + get_highest_rank would return None for them, and + "target_rank is not None and ..." silently passes on None. Otherwise strict `<`: a target whose highest-ranked role ties the actor's own is still a fair target (lets peers at the same rank, e.g. @@ -97,15 +99,15 @@ def validate_member_target(actor: "User", tournament: "Tournament", membership: strictly outranks the actor is protected. Exempt when acting on your own membership (you can't outrank yourself). """ - if actor.id == tournament.owner_id or actor.role == "admin": - return - - if membership.user_id == tournament.owner_id: + if membership.user_id == tournament.owner_id and actor.id != tournament.owner_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot act on the tournament owner's membership", ) + if actor.id == tournament.owner_id or actor.role == "admin": + return + if membership.user_id == actor.id: return @@ -130,21 +132,24 @@ def validate_role_action( ) -> None: """ Rank-bound checks for assigning/removing `role` on `membership`. Owner and - platform admins bypass entirely — same rationale as validate_rank_bound - above: this route is gated on MANAGE_MEMBERS alone, MANAGE_TOURNAMENT is - not a bypass. + platform admins bypass the rank checks below — same rationale as + validate_rank_bound above: this route is gated on MANAGE_MEMBERS alone, + MANAGE_TOURNAMENT is not a bypass. The owner-target check is not part of + that bypass, so it runs first and unconditionally. Two independent checks with different strictness, both must pass: - 1. The role being assigned/removed must not tie or outrank the actor's + 1. Whether the target member can be acted on at all — delegated to + validate_member_target (owner protection + strict-`<` rank check, + self-exempt). + 2. The role being assigned/removed must not tie or outrank the actor's own highest rank — strict `<=`, same as validate_rank_bound for role definitions. This has no self-demotion exemption: a member can't remove their own top-ranked role either, since it ties their own rank. Stepping down from a top role requires the Owner/admin bypass (someone else with higher authority does it for them). - 2. Whether the target member can be acted on at all — delegated to - validate_member_target (owner protection + strict-`<` rank check, - self-exempt). """ + validate_member_target(actor, tournament, membership, db) + if actor.id == tournament.owner_id or actor.role == "admin": return @@ -157,5 +162,3 @@ def validate_role_action( status_code=status.HTTP_403_FORBIDDEN, detail="Cannot assign or remove a role that ties or outranks your own", ) - - validate_member_target(actor, tournament, membership, db) diff --git a/backend/app/db/seed_canon_events.py b/backend/app/db/seed_canon_events.py index f1b38e5d..f4b37e6d 100644 --- a/backend/app/db/seed_canon_events.py +++ b/backend/app/db/seed_canon_events.py @@ -27,7 +27,7 @@ "Air Trajectory": "Technology & Engineering", "Amphibians & Reptiles": "Life, Personal & Social Science", "Anatomy": "Life, Personal & Social Science", - "Anatomy & Physiology": "Life, Personal & Social Science", + "Anatomy and Physiology": "Life, Personal & Social Science", "Astronomy": "Earth & Space Science", "Awesome Aquifers": "Earth & Space Science", "Balancing Equations": "Physical Science & Chemistry", @@ -36,6 +36,7 @@ "Battery Buggy": "Technology & Engineering", "Bio-Process Lab": "Life, Personal & Social Science", "Boomilever": "Technology & Engineering", + "Botany": "Life, Personal & Social Science", "Bottle Rocket": "Technology & Engineering", "Bridge": "Technology & Engineering", "Bridge Building": "Technology & Engineering", diff --git a/backend/app/db/seed_season_events_2027.py b/backend/app/db/seed_season_events_2027.py new file mode 100644 index 00000000..5b9652bd --- /dev/null +++ b/backend/app/db/seed_season_events_2027.py @@ -0,0 +1,109 @@ +""" +One-time script: populate season_events for the 2027 season (Division B + C). + +Renames the pre-existing "Anatomy & Physiology" canon event to "Anatomy and +Physiology" (matches the 2027 event list spelling), ensures canon events/ +categories are seeded (adds "Botany"), then upserts season_events rows for +2027 marked is_active=True. + +Run directly: + python -m app.db.seed_season_events_2027 +""" + +from sqlalchemy.orm import Session +from sqlalchemy.dialects.postgresql import insert + +from app.db.seed_canon_events import seed_events_and_categories + +YEAR = 2027 + +DIVISION_B_EVENTS = [ + "Anatomy and Physiology", + "Botany", + "Disease Detectives", + "Heredity", + "Water Quality", + "Dynamic Planet", + "Meteorology", + "Remote Sensing", + "Rocks and Minerals", + "Solar System", + "Circuit Lab", + "Crime Busters", + "Food Science", + "Hovercraft", + "Thermodynamics", + "Boomilever", + "Elastic Launched Glider", + "Roller Coaster", + "Scrambler", + "Codebusters", + "Experimental Design", + "Ping-Pong Parachute", + "Write It Do It", +] + +DIVISION_C_EVENTS = [ + "Anatomy and Physiology", + "Botany", + "Designer Genes", + "Disease Detectives", + "Water Quality", + "Astronomy", + "Dynamic Planet", + "Remote Sensing", + "Rocks and Minerals", + "Chemistry Lab", + "Circuit Lab", + "Forensics", + "Hovercraft", + "Protein Modeling", + "Boomilever", + "Electric Vehicle", + "Mission Possible", + "Wright Stuff", + "Codebusters", + "Engineering CAD", + "Experimental Design", + "Ping-Pong Parachute", +] + + +def seed_season_events_2027(db: Session) -> None: + from app.models.models import Event, SeasonEvent + + # Old canon spelling predates the 2027 list — rename in place so the + # unique `name` constraint doesn't produce a duplicate event. + old = db.query(Event).filter(Event.name == "Anatomy & Physiology").first() + if old is not None: + old.name = "Anatomy and Physiology" + db.commit() + + seed_events_and_categories(db) + + event_id_by_name = {name: eid for eid, name in db.query(Event.id, Event.name).all()} + + rows = [] + for division, names in (("B", DIVISION_B_EVENTS), ("C", DIVISION_C_EVENTS)): + for name in names: + event_id = event_id_by_name.get(name) + if event_id is None: + raise ValueError(f"Event {name!r} not found in canon events table") + rows.append({"event_id": event_id, "year": YEAR, "division": division, "is_active": True}) + + stmt = insert(SeasonEvent).values(rows) + stmt = stmt.on_conflict_do_update( + index_elements=["event_id", "year", "division"], + set_={"is_active": stmt.excluded.is_active}, + ) + db.execute(stmt) + db.commit() + + print(f"Seeded {len(rows)} season_events rows for {YEAR} (Division B: {len(DIVISION_B_EVENTS)}, Division C: {len(DIVISION_C_EVENTS)}).") + + +if __name__ == "__main__": + from app.db.session import SessionLocal + + with SessionLocal() as db: + seed_season_events_2027(db) diff --git a/backend/app/main.py b/backend/app/main.py index 98cd7aaf..e645f541 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,11 +7,12 @@ from app.core.security import verify_api_key from app.db.init_db import init_db, seed_dev_data from app.api.routes import ( - auth, events, join, + auth, events, join, season_event, sheets, users, user_experience, universities, ) from app.api.routes import tournament as tournament_core from app.api.routes.tournament import events as tournament_events +from app.api.routes.tournament import shifts as tournament_shifts from app.api.routes.tournament import memberships as tournament_memberships from app.api.routes.tournament import roles as tournament_roles from app.api.routes.tournament import join_codes as tournament_join_codes @@ -89,8 +90,13 @@ def _run_archive_job(): app.include_router(auth.router, prefix="", dependencies=[api_key_dependency]) app.include_router(join.router, prefix="", dependencies=[api_key_dependency]) app.include_router(events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(events.admin_router, prefix="", dependencies=[api_key_dependency]) +app.include_router(season_event.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(season_event.admin_router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_core.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_shifts.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(tournament_shifts.event_shifts_router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_memberships.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_roles.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournament_roles.membership_roles_router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 6b692cb7..88dca5a2 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone from sqlalchemy import ( Integer, String, Text, Boolean, Date, DateTime, JSON, - ForeignKey, UniqueConstraint, CheckConstraint, Column, event, + ForeignKey, UniqueConstraint, CheckConstraint, Column, event, Index, ) from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import relationship @@ -240,6 +240,10 @@ class Tournament(Base): level = Column(String(32), nullable=False) # "regionals" | "state" | "nationals" | "invitational" division = Column(JSON, nullable=False, default=list) # "A" | "B" | "C" + # IANA name (e.g. "America/Los_Angeles"). Set once at creation from the + # creator's browser timezone — immutable after, no update path. + timezone = Column(String(64), nullable=False) + owner_id = Column(Integer, ForeignKey("users.id"), nullable=False) # creator # TD-controlled — shows in the public directory. False = invite-only. @@ -275,6 +279,7 @@ class Tournament(Base): roles = relationship("TournamentRole", back_populates="tournament", cascade="all, delete-orphan") join_codes = relationship("JoinCode", back_populates="tournament", cascade="all, delete-orphan") audit_log = relationship("AuditLogEntry", back_populates="tournament", cascade="all, delete-orphan") + event_shifts = relationship("TournamentShift", back_populates="tournament", cascade="all, delete-orphan") # Exactly one of university_id/location (XOR). Checked at flush, not @@ -305,10 +310,6 @@ class TournamentMembership(Base): tournament_id = Column( Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - assigned_event_id = Column( - Integer, ForeignKey("tournament_events.id", ondelete="SET NULL"), nullable=True - ) - source = Column(String(32), nullable=False) # "join_code" | "public" | "manual" # Join code redeemed, if source=="join_code". SET NULL on code delete so @@ -317,8 +318,6 @@ class TournamentMembership(Base): Integer, ForeignKey("join_codes.id", ondelete="SET NULL"), nullable=True ) - schedule = Column(JSON, nullable=True) # [{block, duty}, ...] — day-of assignments - # "interested" | "confirmed" status = Column(String(32), nullable=False, default="interested") @@ -340,7 +339,6 @@ class TournamentMembership(Base): # Relationships user = relationship("User", back_populates="memberships") tournament = relationship("Tournament", back_populates="memberships") - assigned_event = relationship("TournamentEvent", back_populates="memberships") roles = relationship("TournamentMembershipRole", back_populates="membership", cascade="all, delete-orphan") join_code = relationship("JoinCode") @@ -476,27 +474,103 @@ class TournamentEvent(Base): Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - name = Column(String(255), nullable=False) - division = Column(String(4), nullable=False) # "B" | "C" + # Custom (event_id-less) events only + name = Column(String(255), nullable=True) + division = Column(String(4), nullable=True) # "A" | "B" | "C" event_type = Column(String(32), nullable=False, default="standard") # "standard" | "trial" - category = Column(String(255), nullable=True) - + + # Canonical event table link — SET NULL on delete so custom (event_id-less) + # events are just the default, not a broken reference. + event_id = Column(Integer, ForeignKey("events.id", ondelete="SET NULL"), nullable=True) + building = Column(String(255), nullable=True) room = Column(String(64), nullable=True) floor = Column(String(64), nullable=True) - - volunteers_needed = Column(Integer, nullable=False, default=2) - - blocks = Column(JSON, nullable=False, default=list) # [1,2,3,4,5,6] - + + volunteers_needed = Column(Integer, nullable=True) + + # Nullable — tournament planning starts before per-event times are known. + # Frontend is expected to warn on unset times, not block on them here. + start_time = Column(DateTime(timezone=True), nullable=True) + end_time = Column(DateTime(timezone=True), nullable=True) + created_at = Column(DateTime(timezone=True), default=utcnow) updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) tournament = relationship("Tournament", back_populates="events") - memberships = relationship("TournamentMembership", back_populates="assigned_event") + event = relationship("Event") + shifts = relationship( + "TournamentShift", secondary="tournament_event_shifts", back_populates="tournament_events" + ) + + __table_args__ = ( + Index( + "uq_tournament_event_catalog_division", + "tournament_id", "event_id", "division", + unique=True, + postgresql_where=(event_id.isnot(None)), + ), + ) + + +# --------------------------------------------------------------------------- +# TournamentShift — a tournament-scoped time window (e.g. "Shift 1, 8am-12pm") +# that can be attached to one or more TournamentEvents via the +# tournament_event_shifts bridge table. +# --------------------------------------------------------------------------- +class TournamentShift(Base): + __tablename__ = "tournament_shifts" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column(Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False) + label = Column(String(255), nullable=False) + start = Column(DateTime(timezone=True), nullable=False) + end = Column(DateTime(timezone=True), nullable=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="event_shifts") + tournament_events = relationship( + "TournamentEvent", secondary="tournament_event_shifts", back_populates="shifts" + ) + + # Read by TournamentShiftRead — how many events this shift is attached + # to, for the delete-confirm warning. Callers that list many shifts + # should eager-load tournament_events (see list_shifts) to avoid N+1. + @property + def event_count(self) -> int: + return len(self.tournament_events) + + +# --------------------------------------------------------------------------- +# TournamentEventShift — bridge table: TournamentEvent <-> TournamentShift. +# --------------------------------------------------------------------------- +class TournamentEventShift(Base): + __tablename__ = "tournament_event_shifts" + + tournament_event_id = Column(Integer, ForeignKey("tournament_events.id", ondelete="CASCADE"), primary_key=True) + tournament_shift_id = Column(Integer, ForeignKey("tournament_shifts.id", ondelete="CASCADE"), primary_key=True) + + +# --------------------------------------------------------------------------- +# SeasonEvent — admin-curated "this canonical event, in this division, is +# active this year" list. Drives the tournament events bulk-load default +# list (see TournamentEvent); independent of any single tournament. +# --------------------------------------------------------------------------- +class SeasonEvent(Base): + __tablename__ = "season_events" + + id = Column(Integer, primary_key=True, index=True) + event_id = Column(Integer, ForeignKey("events.id", ondelete="CASCADE"), nullable=False) + year = Column(Integer, nullable=False) + division = Column(String(4), nullable=False) + is_active = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime(timezone=True), default=utcnow) + + event = relationship("Event") __table_args__ = ( - UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), + UniqueConstraint("event_id", "year", "division", name="uq_season_event"), ) diff --git a/backend/app/schemas/join_code.py b/backend/app/schemas/join_code.py index ba9b7605..799f76c0 100644 --- a/backend/app/schemas/join_code.py +++ b/backend/app/schemas/join_code.py @@ -1,11 +1,11 @@ from __future__ import annotations -from datetime import date, datetime +from datetime import datetime from typing import Literal from pydantic import BaseModel, EmailStr from app.schemas.user import UserSlimResponse +from app.schemas.tournament import TournamentPublic from app.schemas.tournament.membership import MembershipSlimResponse -from app.schemas.university import UniversityResponse class JoinCodeResponse(BaseModel): @@ -61,24 +61,14 @@ class JoinRedeemResponse(BaseModel): membership_id: int -class JoinPreviewTournament(BaseModel): +class JoinPreviewTournament(TournamentPublic): """type=="tournament" branch of JoinPreviewResponse — public, read-only, so an invite link can show what's being joined before the visitor is - even signed in. Deliberately minimal: no owner/roles/registration - internals, just what's needed to decide whether to join.""" + even signed in. Shares its fields with TournamentSummary via + TournamentPublic; adds only what this route needs on top.""" type: Literal["tournament"] = "tournament" target_id: int - name: str - short_name: str | None = None - start_date: date - end_date: date - university: UniversityResponse | None = None - location: str | None = None - state: str - level: str - division: list[str] - is_verified: bool class JoinPreviewChapter(BaseModel): diff --git a/backend/app/schemas/season_event.py b/backend/app/schemas/season_event.py new file mode 100644 index 00000000..22a812ae --- /dev/null +++ b/backend/app/schemas/season_event.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, field_validator + +from app.schemas.event import EventResponse +from app.schemas.tournament import VALID_DIVISIONS + + +class SeasonEventCreate(BaseModel): + event_id: int + year: int + division: str + is_active: bool = False + + @field_validator("division") + @classmethod + def validate_division(cls, v: str) -> str: + if v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + +class SeasonEventUpdate(BaseModel): + """Partial update — primarily used to toggle is_active.""" + year: int | None = None + division: str | None = None + is_active: bool | None = None + + @field_validator("division") + @classmethod + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + +class SeasonEventRead(BaseModel): + id: int + year: int + division: str + is_active: bool + event: EventResponse + created_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tournament/__init__.py b/backend/app/schemas/tournament/__init__.py index 8fc5c5e3..bd087856 100644 --- a/backend/app/schemas/tournament/__init__.py +++ b/backend/app/schemas/tournament/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations from datetime import date, datetime +from zoneinfo import available_timezones from pydantic import BaseModel, field_validator, model_validator from app.schemas.tournament.role import RoleRead @@ -48,6 +49,12 @@ def _validate_division(v: list[str]) -> list[str]: return sorted(set(v)) +def _validate_timezone(v: str) -> str: + if v not in available_timezones(): + raise ValueError("timezone must be a valid IANA timezone name") + return v + + class TournamentFieldValidators: """Shared field validators for TournamentCreate/TournamentUpdate. Mixed in rather than inherited from a common BaseModel because the two differ on @@ -88,6 +95,14 @@ class TournamentCreate(TournamentFieldValidators, BaseModel): level: str division: list[str] is_public: bool = False + # Set once from the creator's browser timezone — no update path exists, + # this field is intentionally absent from TournamentUpdate. + timezone: str + + @field_validator("timezone") + @classmethod + def validate_timezone(cls, v: str) -> str: + return _validate_timezone(v) @model_validator(mode="after") def validate_dates(self) -> TournamentCreate: @@ -156,3 +171,35 @@ class TournamentRead(BaseModel): updated_at: datetime model_config = {"from_attributes": True} + + +class TournamentPublic(BaseModel): + """Shared minimal, non-sensitive tournament fields — what any viewer, + member or not, needs to identify a tournament and decide whether to + engage with it. No owner/roles/registration internals. Base for + TournamentSummary (dashboard list) and JoinPreviewTournament (public + invite preview) — deliberately has no id/target_id field since the two + callers use different names for it.""" + name: str + short_name: str | None = None + start_date: date + end_date: date + university: UniversityResponse | None = None + location: str | None = None + state: str + level: str + division: list[str] + is_verified: bool + + model_config = {"from_attributes": True} + + +class TournamentSummary(TournamentPublic): + """GET /tournaments/me/ — lightweight card view, no roles/owner.""" + id: int + is_public: bool + is_archived: bool + event_count: int + volunteer_count: int + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/tournament/event.py b/backend/app/schemas/tournament/event.py index 064d6c0d..274f6e10 100644 --- a/backend/app/schemas/tournament/event.py +++ b/backend/app/schemas/tournament/event.py @@ -1,28 +1,34 @@ from __future__ import annotations from datetime import datetime -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, field_validator, model_validator + +from app.schemas.event import EventResponse +from app.schemas.tournament import VALID_DIVISIONS +from app.schemas.tournament.shift import TournamentShiftRead -VALID_DIVISIONS = {"B", "C"} VALID_EVENT_TYPES = {"standard", "trial"} class EventBase(BaseModel): - name: str - division: str + # Custom (event_id-less) events only — catalog-linked events display + # the joined Event.name instead. See the model_validator below. + name: str | None = None + division: str | None = None event_type: str = "standard" - category: str | None = None + event_id: int | None = None building: str | None = None room: str | None = None floor: str | None = None - volunteers_needed: int = 2 - # Block numbers this event runs e.g. [1,2,3,4,5,6] - # Empty list means the TD hasn't configured blocks yet - blocks: list[int] = [] + volunteers_needed: int | None = None + # Nullable — a tournament's event schedule isn't known at planning + # time. Frontend is expected to warn on unset times, not block on them. + start_time: datetime | None = None + end_time: datetime | None = None @field_validator("division") @classmethod - def validate_division(cls, v: str) -> str: - if v not in VALID_DIVISIONS: + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: raise ValueError(f"division must be one of: {VALID_DIVISIONS}") return v @@ -35,19 +41,22 @@ def validate_event_type(cls, v: str) -> str: @field_validator("volunteers_needed") @classmethod - def validate_volunteers_needed(cls, v: int) -> int: - if v < 1: + def validate_volunteers_needed(cls, v: int | None) -> int | None: + if v is not None and v < 1: raise ValueError("volunteers_needed must be at least 1") return v - @field_validator("blocks") - @classmethod - def validate_blocks(cls, v: list[int]) -> list[int]: - if len(v) != len(set(v)): - raise ValueError("Block numbers must be unique") - if any(b < 1 for b in v): - raise ValueError("Block numbers must be positive integers") - return sorted(v) + @model_validator(mode="after") + def validate_times(self) -> "EventBase": + if self.start_time is not None and self.end_time is not None and self.end_time <= self.start_time: + raise ValueError("end_time must be after start_time") + return self + + @model_validator(mode="after") + def validate_name_or_event_id(self) -> "EventBase": + if self.name is None and self.event_id is None: + raise ValueError("at least one of name or event_id must be set") + return self class EventCreate(EventBase): @@ -55,22 +64,78 @@ class EventCreate(EventBase): class EventUpdate(BaseModel): - """Partial update — all fields optional.""" + """Partial update — all fields optional. Only the fields actually sent + are validated against each other (mirrors current + incoming values is + the route's job, not this schema's).""" name: str | None = None division: str | None = None event_type: str | None = None - category: str | None = None + event_id: int | None = None building: str | None = None room: str | None = None floor: str | None = None volunteers_needed: int | None = None - blocks: list[int] | None = None + start_time: datetime | None = None + end_time: datetime | None = None + + @field_validator("division") + @classmethod + def validate_division(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_DIVISIONS: + raise ValueError(f"division must be one of: {VALID_DIVISIONS}") + return v + + @field_validator("event_type") + @classmethod + def validate_event_type(cls, v: str | None) -> str | None: + if v is not None and v not in VALID_EVENT_TYPES: + raise ValueError(f"event_type must be one of: {VALID_EVENT_TYPES}") + return v + @field_validator("volunteers_needed") + @classmethod + def validate_volunteers_needed(cls, v: int | None) -> int | None: + if v is not None and v < 1: + raise ValueError("volunteers_needed must be at least 1") + return v + + @model_validator(mode="after") + def validate_times(self) -> "EventUpdate": + if self.start_time is not None and self.end_time is not None and self.end_time <= self.start_time: + raise ValueError("end_time must be after start_time") + return self -class EventRead(EventBase): + +class EventRead(BaseModel): id: int tournament_id: int + name: str | None = None + division: str | None = None + event_type: str + event_id: int | None = None + # Joined canonical event — set only when event_id is set. Carries + # category, since TournamentEvent no longer has its own category field. + event: EventResponse | None = None + building: str | None = None + room: str | None = None + floor: str | None = None + volunteers_needed: int | None = None + start_time: datetime | None = None + end_time: datetime | None = None + shifts: list[TournamentShiftRead] = [] created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} \ No newline at end of file + model_config = {"from_attributes": True} + + +class EventLoadDefaultsSkipped(BaseModel): + event_id: int + division: str + name: str + reason: str = "already loaded" + + +class EventLoadDefaultsResponse(BaseModel): + created: list[EventRead] + skipped: list[EventLoadDefaultsSkipped] diff --git a/backend/app/schemas/tournament/membership.py b/backend/app/schemas/tournament/membership.py index d2e31350..ac136d5b 100644 --- a/backend/app/schemas/tournament/membership.py +++ b/backend/app/schemas/tournament/membership.py @@ -16,12 +16,6 @@ class AvailabilitySlot(BaseModel): end: str # "HH:MM" -class ScheduleSlot(BaseModel): - """A single day-of block assignment.""" - block: int # block number - duty: str # role label or free string, e.g. "Event Supervisor" - - class MembershipMeUpdate(BaseModel): """Self-service update — the fields a volunteer fills out during onboarding. @@ -36,7 +30,6 @@ class MembershipMeUpdate(BaseModel): class MembershipCoordinatorUpdate(BaseModel): """manage_members override — day-of logistics only, not onboarding data.""" - schedule: list[ScheduleSlot] | None = None notes: Optional[str] = None @@ -105,8 +98,6 @@ class MembershipFullResponse(_MembershipRolesMixin): """Detail view — the expanded side panel for a single member.""" id: int tournament_id: int - assigned_event_id: int | None = None - schedule: list[ScheduleSlot] | None = None status: str role_preference: list[str] | None = None event_preference: list[str] | None = None diff --git a/backend/app/schemas/tournament/shift.py b/backend/app/schemas/tournament/shift.py new file mode 100644 index 00000000..9070f628 --- /dev/null +++ b/backend/app/schemas/tournament/shift.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, model_validator + + +class TournamentShiftCreate(BaseModel): + label: str + start: datetime + end: datetime + + @model_validator(mode="after") + def validate_times(self) -> "TournamentShiftCreate": + if self.end <= self.start: + raise ValueError("end must be after start") + return self + + +class TournamentShiftUpdate(BaseModel): + label: str | None = None + start: datetime | None = None + end: datetime | None = None + + @model_validator(mode="after") + def validate_times(self) -> "TournamentShiftUpdate": + if self.start is not None and self.end is not None and self.end <= self.start: + raise ValueError("end must be after start") + return self + + +class TournamentShiftRead(BaseModel): + id: int + tournament_id: int + label: str + start: datetime + end: datetime + # How many TournamentEvents this shift is currently attached to — drives + # the "attached to N events" delete-confirm warning. + event_count: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index 5fd1265e..b6c7eec9 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -28,7 +28,7 @@ def test_list_events_anonymous_allowed(self, client, event_category_factory, eve class TestCreateEvent: def test_admin_can_create_event(self, client, admin_user, event_category): login(client, "admin@test.com", "adminpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 201 data = res.json() assert data["name"] == "Boomilever" @@ -36,16 +36,16 @@ def test_admin_can_create_event(self, client, admin_user, event_category): def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 403 def test_unauthenticated_forbidden(self, client, event_category): - res = client.post("/events/", json={"name": "Boomilever", "category_id": event_category.id}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": event_category.id}) assert res.status_code == 401 def test_invalid_category_id_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - res = client.post("/events/", json={"name": "Boomilever", "category_id": 9999}) + res = client.post("/admin/events/", json={"name": "Boomilever", "category_id": 9999}) assert res.status_code == 404 @@ -56,7 +56,7 @@ def test_invalid_category_id_404(self, client, admin_user): class TestUpdateEvent: def test_partial_update(self, client, admin_user, event): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"name": "Hovercraft"}) + res = client.patch(f"/admin/events/{event.id}/", json={"name": "Hovercraft"}) assert res.status_code == 200 assert res.json()["name"] == "Hovercraft" @@ -65,22 +65,22 @@ def test_update_category_id(self, client, admin_user, event_category_factory, ev other_category = event_category_factory("Physics") event = event_factory(category) login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"category_id": other_category.id}) + res = client.patch(f"/admin/events/{event.id}/", json={"category_id": other_category.id}) assert res.status_code == 200 assert res.json()["category"]["id"] == other_category.id def test_missing_event_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.patch("/events/9999/", json={"name": "Hovercraft"}).status_code == 404 + assert client.patch("/admin/events/9999/", json={"name": "Hovercraft"}).status_code == 404 def test_invalid_category_id_404(self, client, admin_user, event): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/events/{event.id}/", json={"category_id": 9999}) + res = client.patch(f"/admin/events/{event.id}/", json={"category_id": 9999}) assert res.status_code == 404 def test_non_admin_forbidden(self, client, td_user, event): login(client, "td@test.com", "tdpass") - assert client.patch(f"/events/{event.id}/", json={"name": "Hovercraft"}).status_code == 403 + assert client.patch(f"/admin/events/{event.id}/", json={"name": "Hovercraft"}).status_code == 403 # --------------------------------------------------------------------------- @@ -90,14 +90,14 @@ def test_non_admin_forbidden(self, client, td_user, event): class TestDeleteEvent: def test_delete_with_no_experience_entries(self, client, db, admin_user, event): login(client, "admin@test.com", "adminpass") - assert client.delete(f"/events/{event.id}/").status_code == 204 + assert client.delete(f"/admin/events/{event.id}/").status_code == 204 assert db.get(Event, event.id) is None def test_delete_blocked_by_competition_experience(self, client, db, admin_user, td_user, event): db.add(UserCompetitionExperience(user_id=td_user.id, event_id=event.id, school="MIT")) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/events/{event.id}/") + res = client.delete(f"/admin/events/{event.id}/") assert res.status_code == 409 assert db.get(Event, event.id) is not None @@ -108,17 +108,17 @@ def test_delete_blocked_by_volunteer_experience(self, client, db, admin_user, td )) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/events/{event.id}/") + res = client.delete(f"/admin/events/{event.id}/") assert res.status_code == 409 assert db.get(Event, event.id) is not None def test_delete_not_found(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.delete("/events/9999/").status_code == 404 + assert client.delete("/admin/events/9999/").status_code == 404 def test_non_admin_forbidden(self, client, td_user, event): login(client, "td@test.com", "tdpass") - assert client.delete(f"/events/{event.id}/").status_code == 403 + assert client.delete(f"/admin/events/{event.id}/").status_code == 403 # --------------------------------------------------------------------------- @@ -139,16 +139,16 @@ def test_list_categories(self, client, event_category): class TestCreateEventCategory: def test_admin_can_create_category(self, client, admin_user): login(client, "admin@test.com", "adminpass") - res = client.post("/event-categories/", json={"name": "Chemistry"}) + res = client.post("/admin/event-categories/", json={"name": "Chemistry"}) assert res.status_code == 201 assert res.json()["name"] == "Chemistry" def test_non_admin_forbidden(self, client, td_user): login(client, "td@test.com", "tdpass") - assert client.post("/event-categories/", json={"name": "Chemistry"}).status_code == 403 + assert client.post("/admin/event-categories/", json={"name": "Chemistry"}).status_code == 403 def test_unauthenticated_forbidden(self, client): - assert client.post("/event-categories/", json={"name": "Chemistry"}).status_code == 401 + assert client.post("/admin/event-categories/", json={"name": "Chemistry"}).status_code == 401 # --------------------------------------------------------------------------- @@ -158,17 +158,17 @@ def test_unauthenticated_forbidden(self, client): class TestUpdateEventCategory: def test_partial_update(self, client, admin_user, event_category): login(client, "admin@test.com", "adminpass") - res = client.patch(f"/event-categories/{event_category.id}/", json={"name": "Physics"}) + res = client.patch(f"/admin/event-categories/{event_category.id}/", json={"name": "Physics"}) assert res.status_code == 200 assert res.json()["name"] == "Physics" def test_missing_category_404(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.patch("/event-categories/9999/", json={"name": "Physics"}).status_code == 404 + assert client.patch("/admin/event-categories/9999/", json={"name": "Physics"}).status_code == 404 def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - res = client.patch(f"/event-categories/{event_category.id}/", json={"name": "Physics"}) + res = client.patch(f"/admin/event-categories/{event_category.id}/", json={"name": "Physics"}) assert res.status_code == 403 @@ -179,7 +179,7 @@ def test_non_admin_forbidden(self, client, td_user, event_category): class TestDeleteEventCategory: def test_delete_cascades_to_events(self, client, db, admin_user, event_category, event): login(client, "admin@test.com", "adminpass") - assert client.delete(f"/event-categories/{event_category.id}/").status_code == 204 + assert client.delete(f"/admin/event-categories/{event_category.id}/").status_code == 204 assert db.get(EventCategory, event_category.id) is None assert db.get(Event, event.id) is None @@ -189,7 +189,7 @@ def test_delete_blocked_when_event_has_experience_entries( db.add(UserCompetitionExperience(user_id=td_user.id, event_id=event.id, school="MIT")) db.commit() login(client, "admin@test.com", "adminpass") - res = client.delete(f"/event-categories/{event_category.id}/") + res = client.delete(f"/admin/event-categories/{event_category.id}/") assert res.status_code == 409 # Rollback — category and its event should still exist. assert db.get(EventCategory, event_category.id) is not None @@ -197,8 +197,8 @@ def test_delete_blocked_when_event_has_experience_entries( def test_delete_not_found(self, client, admin_user): login(client, "admin@test.com", "adminpass") - assert client.delete("/event-categories/9999/").status_code == 404 + assert client.delete("/admin/event-categories/9999/").status_code == 404 def test_non_admin_forbidden(self, client, td_user, event_category): login(client, "td@test.com", "tdpass") - assert client.delete(f"/event-categories/{event_category.id}/").status_code == 403 + assert client.delete(f"/admin/event-categories/{event_category.id}/").status_code == 403 diff --git a/backend/tests/api/test_season_events.py b/backend/tests/api/test_season_events.py new file mode 100644 index 00000000..1a18e0b3 --- /dev/null +++ b/backend/tests/api/test_season_events.py @@ -0,0 +1,154 @@ +"""Tests for /admin/season-events endpoints (SeasonEvent model) and the +/tournaments/{tournament_id}/events/load-defaults bulk-load action.""" +from tests.conftest import login +from app.models.models import SeasonEvent + + +def _login_admin(client): + login(client, "admin@test.com", "adminpass") + + +def _make_season_event(client, **overrides): + payload = {"year": 2026, "division": "C", "is_active": True} + payload.update(overrides) + return client.post("/admin/season-events/", json=payload) + + +# --------------------------------------------------------------------------- +# Admin CRUD +# --------------------------------------------------------------------------- + +def test_create_season_event(client, admin_user, event): + _login_admin(client) + response = _make_season_event(client, event_id=event.id) + assert response.status_code == 201 + data = response.json() + assert data["event"]["id"] == event.id + assert data["year"] == 2026 + assert data["division"] == "C" + assert data["is_active"] is True + assert data["event"]["name"] == event.name + + +def test_create_season_event_duplicate_rejected(client, admin_user, event): + _login_admin(client) + assert _make_season_event(client, event_id=event.id).status_code == 201 + assert _make_season_event(client, event_id=event.id).status_code == 409 + + +def test_list_season_events_filter_by_year_division(client, admin_user, event_factory, event_category): + _login_admin(client) + e1 = event_factory(event_category, name="Boomilever") + e2 = event_factory(event_category, name="Hovercraft") + _make_season_event(client, event_id=e1.id, year=2026, division="C") + _make_season_event(client, event_id=e2.id, year=2027, division="B") + + response = client.get("/season-events/", params={"year": 2026}) + assert response.status_code == 200 + assert len(response.json()) == 1 + assert response.json()[0]["event"]["id"] == e1.id + + +def test_list_season_events_filter_by_multiple_divisions(client, admin_user, event_factory, event_category): + _login_admin(client) + e_a = event_factory(event_category, name="Anatomy") + e_b = event_factory(event_category, name="Boomilever") + e_c = event_factory(event_category, name="Circuit Lab") + _make_season_event(client, event_id=e_a.id, year=2026, division="A") + _make_season_event(client, event_id=e_b.id, year=2026, division="B") + _make_season_event(client, event_id=e_c.id, year=2026, division="C") + + response = client.get("/season-events/", params={"division": ["B", "C"]}) + assert response.status_code == 200 + ids = {row["event"]["id"] for row in response.json()} + assert ids == {e_b.id, e_c.id} + + +def test_update_season_event_toggle_active(client, admin_user, event): + _login_admin(client) + created = _make_season_event(client, event_id=event.id, is_active=False).json() + response = client.patch(f"/admin/season-events/{created['id']}/", json={"is_active": True}) + assert response.status_code == 200 + assert response.json()["is_active"] is True + + +def test_delete_season_event(client, admin_user, event): + _login_admin(client) + created = _make_season_event(client, event_id=event.id).json() + assert client.delete(f"/admin/season-events/{created['id']}/").status_code == 204 + + +def test_season_event_write_routes_require_admin(client, td_user, event): + """A regular (non-admin) user gets 403 on every admin write route.""" + login(client, "td@test.com", "tdpass") + assert _make_season_event(client, event_id=event.id).status_code == 403 + + +def test_list_season_events_is_public(client, event): + """GET isn't admin-gated — anyone can read the season event list.""" + response = client.get("/season-events/") + assert response.status_code == 200 + + +# --------------------------------------------------------------------------- +# Bulk-load +# --------------------------------------------------------------------------- + +def test_load_defaults_only_pulls_supported_divisions(client, admin_user, td_user, td_tournament, db, event_factory, event_category): + """td_tournament supports divisions B/C only (see conftest).""" + e_c = event_factory(event_category, name="Boomilever") + e_a = event_factory(event_category, name="Elementary Event") + db.add_all([ + SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=True), + SeasonEvent(event_id=e_a.id, year=2026, division="A", is_active=True), + ]) + db.commit() + + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + data = response.json() + assert len(data["created"]) == 1 + assert data["created"][0]["event_id"] == e_c.id + assert data["skipped"] == [] + + +def test_load_defaults_skips_already_loaded(client, admin_user, td_user, td_tournament, db, event_factory, event_category): + e_c = event_factory(event_category, name="Boomilever") + db.add(SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=True)) + db.commit() + + login(client, "td@test.com", "tdpass") + first = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert len(first.json()["created"]) == 1 + + second = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert second.status_code == 201 + assert second.json()["created"] == [] + assert len(second.json()["skipped"]) == 1 + assert second.json()["skipped"][0]["event_id"] == e_c.id + + +def test_load_defaults_no_active_season_events_is_noop(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + assert response.json() == {"created": [], "skipped": []} + + +def test_load_defaults_ignores_inactive_season_events(client, td_user, td_tournament, db, event_factory, event_category): + e_c = event_factory(event_category, name="Boomilever") + db.add(SeasonEvent(event_id=e_c.id, year=2026, division="C", is_active=False)) + db.commit() + + login(client, "td@test.com", "tdpass") + response = client.post(f"/tournaments/{td_tournament.id}/events/load-defaults/") + assert response.status_code == 201 + assert response.json() == {"created": [], "skipped": []} + + +def test_load_defaults_requires_manage_events(client, td_user, other_tournament, db): + from tests.conftest import grant_role + grant_role(db, other_tournament, td_user, "Volunteer") + login(client, "td@test.com", "tdpass") + assert client.post(f"/tournaments/{other_tournament.id}/events/load-defaults/").status_code == 403 diff --git a/backend/tests/api/test_sync.py b/backend/tests/api/test_sync.py index 2de634c7..376af243 100644 --- a/backend/tests/api/test_sync.py +++ b/backend/tests/api/test_sync.py @@ -38,6 +38,7 @@ def _make_tournament(client): "state": "Southern California", "level": "nationals", "division": ["B", "C"], + "timezone": "America/Los_Angeles", }).json() diff --git a/backend/tests/api/tournament/test_core.py b/backend/tests/api/tournament/test_core.py index 46ef7727..89f96764 100644 --- a/backend/tests/api/tournament/test_core.py +++ b/backend/tests/api/tournament/test_core.py @@ -60,6 +60,30 @@ def test_list_my_tournaments_unauthenticated(client): assert client.get("/tournaments/me/").status_code == 401 +def test_list_my_tournaments_returns_summary_shape( + client, td_user, td_tournament, other_user, db +): + """TournamentSummary — event_count/volunteer_count computed, no + roles/owner_id leaked (those belong to the full TournamentRead detail).""" + login(client, "td@test.com", "tdpass") + client.post(f"/tournaments/{td_tournament.id}/events/", json={ + "tournament_id": td_tournament.id, + "name": "Boomilever", + "division": "C", + "start_time": date.today().isoformat() + "T08:00:00Z", + "end_time": date.today().isoformat() + "T12:00:00Z", + }) + grant_role(db, td_tournament, other_user, "Volunteer") + + data = client.get("/tournaments/me/").json() + summary = next(t for t in data if t["id"] == td_tournament.id) + + assert summary["event_count"] == 1 + assert summary["volunteer_count"] == 2 # owner (td_user) + other_user + assert "roles" not in summary + assert "owner_id" not in summary + + # --------------------------------------------------------------------------- # POST /tournaments/ — any authenticated user # --------------------------------------------------------------------------- @@ -119,6 +143,7 @@ def test_create_tournament_full(client, td_user): "state": "Southern California", "level": "nationals", "division": ["B", "C"], + "timezone": "America/Los_Angeles", "is_public": True, }) assert response.status_code == 201 diff --git a/backend/tests/api/tournament/test_events.py b/backend/tests/api/tournament/test_events.py index 9c81f48f..1b495181 100644 --- a/backend/tests/api/tournament/test_events.py +++ b/backend/tests/api/tournament/test_events.py @@ -1,15 +1,24 @@ """Tests for /tournaments/{tournament_id}/events endpoints (TournamentEvent model).""" +from datetime import date, timedelta + import pytest from fastapi.testclient import TestClient from tests.conftest import grant_role, login +# td_tournament spans [today, today + 1 day] — event/shift times must fall +# within that window now that tournament-bounds validation exists. +EVENT_DATE = date.today().isoformat() +BEFORE_TOURNAMENT = (date.today() - timedelta(days=1)).isoformat() +AFTER_TOURNAMENT = (date.today() + timedelta(days=3)).isoformat() + def _make_event(client, tournament_id, **overrides): payload = { "tournament_id": tournament_id, "name": "Boomilever", "division": "C", - "blocks": [1, 2, 3, 4, 5, 6], + "start_time": EVENT_DATE + "T08:00:00Z", + "end_time": EVENT_DATE + "T12:00:00Z", } payload.update(overrides) return client.post(f"/tournaments/{tournament_id}/events/", json=payload) @@ -28,27 +37,90 @@ def test_create_event_minimal(client, td_user, td_tournament): assert data["division"] == "C" assert data["tournament_id"] == td_tournament.id assert data["event_type"] == "standard" - assert data["volunteers_needed"] == 2 + assert data["volunteers_needed"] is None def test_create_event_full(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") response = _make_event(client, td_tournament.id, name="Hovercraft", division="B", event_type="trial", - category="Technology & Engineering", building="Main Hall", - room="101", floor="1", volunteers_needed=3, blocks=[1, 2, 3], + building="Main Hall", room="101", floor="1", volunteers_needed=3, ) assert response.status_code == 201 data = response.json() - assert data["category"] == "Technology & Engineering" assert data["volunteers_needed"] == 3 assert data["event_type"] == "trial" -def test_create_event_duplicate_rejected(client, td_user, td_tournament): +def test_create_event_with_catalog_link_inherits_category(client, td_user, td_tournament, event): + """Setting event_id joins the canonical Event — category comes from + the join, not a column on TournamentEvent.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, name=None, event_id=event.id) + assert response.status_code == 201 + data = response.json() + assert data["event_id"] == event.id + assert data["event"]["category"]["name"] == event.category.name + + +def test_create_event_without_catalog_link_has_no_category(client, td_user, td_tournament): + """Custom (event_id-less) events have no category — nothing fabricated.""" login(client, "td@test.com", "tdpass") - _make_event(client, td_tournament.id) - assert _make_event(client, td_tournament.id).status_code == 409 + response = _make_event(client, td_tournament.id) + assert response.status_code == 201 + assert response.json()["event"] is None + + +def test_create_event_duplicate_catalog_division_rejected(client, td_user, td_tournament, event): + login(client, "td@test.com", "tdpass") + first = _make_event(client, td_tournament.id, name=None, event_id=event.id, division="C") + assert first.status_code == 201 + second = _make_event(client, td_tournament.id, name=None, event_id=event.id, division="C") + assert second.status_code == 409 + + +def test_create_event_two_custom_events_same_name_both_succeed(client, td_user, td_tournament): + """Custom events have no uniqueness constraint at all.""" + login(client, "td@test.com", "tdpass") + first = _make_event(client, td_tournament.id, name="Boomilever", division="C") + second = _make_event(client, td_tournament.id, name="Boomilever", division="C") + assert first.status_code == 201 + assert second.status_code == 201 + + +def test_create_event_division_not_in_tournament_divisions(client, td_user, td_tournament): + """td_tournament only supports divisions B/C.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, division="A") + assert response.status_code == 422 + + +def test_create_event_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_event( + client, td_tournament.id, + start_time=EVENT_DATE + "T12:00:00Z", end_time=EVENT_DATE + "T08:00:00Z", + ) + assert response.status_code == 422 + + +def test_create_event_start_before_tournament_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, start_time=BEFORE_TOURNAMENT + "T08:00:00Z") + assert response.status_code == 409 + + +def test_create_event_end_after_tournament_end_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, end_time=AFTER_TOURNAMENT + "T12:00:00Z") + assert response.status_code == 409 + + +def test_create_event_without_times_skips_bounds_check(client, td_user, td_tournament): + """start_time/end_time are nullable — bounds only apply once set.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, start_time=None, end_time=None) + assert response.status_code == 201 def test_create_event_tournament_id_mismatch(client, td_user, td_tournament): @@ -57,7 +129,8 @@ def test_create_event_tournament_id_mismatch(client, td_user, td_tournament): "tournament_id": 9999, "name": "Boomilever", "division": "C", - "blocks": [], + "start_time": EVENT_DATE + "T08:00:00Z", + "end_time": EVENT_DATE + "T12:00:00Z", }) assert response.status_code == 400 @@ -162,6 +235,36 @@ def test_update_event(client, td_user, td_tournament): assert response.json()["building"] == "Science Hall" +def test_update_event_division_not_in_tournament_divisions(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_event(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{created['id']}/", + json={"division": "A"}, + ) + assert response.status_code == 422 + + +def test_update_event_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_event(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{created['id']}/", + json={"start_time": EVENT_DATE + "T12:00:00Z", "end_time": EVENT_DATE + "T08:00:00Z"}, + ) + assert response.status_code == 422 + + +def test_update_event_outside_tournament_bounds_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_event(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{created['id']}/", + json={"start_time": BEFORE_TOURNAMENT + "T08:00:00Z"}, + ) + assert response.status_code == 409 + + def test_update_event_volunteer_cannot_patch( client, td_user, other_user, other_tournament, db ): @@ -192,4 +295,4 @@ def test_delete_event(client, td_user, td_tournament): def test_delete_event_not_found(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") - assert client.delete(f"/tournaments/{td_tournament.id}/events/9999/").status_code == 404 \ No newline at end of file + assert client.delete(f"/tournaments/{td_tournament.id}/events/9999/").status_code == 404 diff --git a/backend/tests/api/tournament/test_memberships.py b/backend/tests/api/tournament/test_memberships.py index 8010ae7e..acae2e0a 100644 --- a/backend/tests/api/tournament/test_memberships.py +++ b/backend/tests/api/tournament/test_memberships.py @@ -470,21 +470,6 @@ def test_update_my_membership_invalid_availability_shape(client, td_tournament, # Coordinator update — PATCH .../{membership_id}/ # --------------------------------------------------------------------------- -def test_update_membership_schedule(client, td_user, td_tournament, db): - u = _make_user(db) - m = _make_membership(db, td_tournament.id, u["id"]) - login(client, "td@test.com", "tdpass") - response = client.patch( - f"/tournaments/{td_tournament.id}/memberships/{m.id}/", - json={"schedule": [ - {"block": 1, "duty": "event_supervisor"}, - {"block": 7, "duty": "scoring"}, - ]}, - ) - assert response.status_code == 200 - assert response.json()["schedule"][1]["duty"] == "scoring" - - def test_update_membership_notes(client, td_user, td_tournament, db): u = _make_user(db) m = _make_membership(db, td_tournament.id, u["id"]) diff --git a/backend/tests/api/tournament/test_roles.py b/backend/tests/api/tournament/test_roles.py index 30ee503c..22444ddd 100644 --- a/backend/tests/api/tournament/test_roles.py +++ b/backend/tests/api/tournament/test_roles.py @@ -87,6 +87,7 @@ def _make_empty_tournament(client, name="Empty Tournament") -> int: "state": "Southern California", "level": "invitational", "division": ["B", "C"], + "timezone": "America/Los_Angeles", }) assert response.status_code == 201 return response.json()["id"] diff --git a/backend/tests/api/tournament/test_shifts.py b/backend/tests/api/tournament/test_shifts.py new file mode 100644 index 00000000..20a7538e --- /dev/null +++ b/backend/tests/api/tournament/test_shifts.py @@ -0,0 +1,237 @@ +"""Tests for /tournaments/{tournament_id}/shifts endpoints (TournamentShift model) +and event/shift attach-detach.""" +from datetime import date, timedelta + +from tests.conftest import grant_role, login + +# td_tournament spans [today, today + 1 day] — event/shift times must fall +# within that window now that tournament-bounds validation exists. +EVENT_DATE = date.today().isoformat() +BEFORE_TOURNAMENT = (date.today() - timedelta(days=1)).isoformat() +AFTER_TOURNAMENT = (date.today() + timedelta(days=3)).isoformat() + + +def _make_shift(client, tournament_id, **overrides): + payload = { + "label": "Shift 1", + "start": EVENT_DATE + "T08:00:00Z", + "end": EVENT_DATE + "T12:00:00Z", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/shifts/", json=payload) + + +def _make_event(client, tournament_id, **overrides): + payload = { + "tournament_id": tournament_id, + "name": "Boomilever", + "division": "C", + "start_time": EVENT_DATE + "T08:00:00Z", + "end_time": EVENT_DATE + "T16:00:00Z", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/events/", json=payload).json() + + +# --------------------------------------------------------------------------- +# Shift CRUD +# --------------------------------------------------------------------------- + +def test_create_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Shift 1" + assert data["tournament_id"] == td_tournament.id + + +def test_create_shift_end_before_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T12:00:00Z", end=EVENT_DATE + "T08:00:00Z") + assert response.status_code == 422 + + +def test_create_shift_before_tournament_start_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id, start=BEFORE_TOURNAMENT + "T08:00:00Z", end=BEFORE_TOURNAMENT + "T12:00:00Z") + assert response.status_code == 409 + + +def test_create_shift_after_tournament_end_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_shift(client, td_tournament.id, start=AFTER_TOURNAMENT + "T08:00:00Z", end=AFTER_TOURNAMENT + "T12:00:00Z") + assert response.status_code == 409 + + +def test_update_shift_outside_tournament_bounds_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_shift(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/shifts/{created['id']}/", + json={"start": AFTER_TOURNAMENT + "T08:00:00Z", "end": AFTER_TOURNAMENT + "T12:00:00Z"}, + ) + assert response.status_code == 409 + + +def test_list_shifts(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_shift(client, td_tournament.id, label="Shift 1") + _make_shift(client, td_tournament.id, label="Shift 2", start=EVENT_DATE + "T12:00:00Z", end=EVENT_DATE + "T16:00:00Z") + response = client.get(f"/tournaments/{td_tournament.id}/shifts/") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_update_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_shift(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/shifts/{created['id']}/", + json={"label": "Renamed Shift"}, + ) + assert response.status_code == 200 + assert response.json()["label"] == "Renamed Shift" + + +def test_shift_event_count(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + assert shift["event_count"] == 0 + + event1 = _make_event(client, td_tournament.id, name="Boomilever") + event2 = _make_event(client, td_tournament.id, name="Hovercraft") + client.post(f"/tournaments/{td_tournament.id}/events/{event1['id']}/shifts/{shift['id']}/") + client.post(f"/tournaments/{td_tournament.id}/events/{event2['id']}/shifts/{shift['id']}/") + + listed = client.get(f"/tournaments/{td_tournament.id}/shifts/").json() + assert next(s for s in listed if s["id"] == shift["id"])["event_count"] == 2 + + +def test_delete_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_shift(client, td_tournament.id).json() + assert client.delete(f"/tournaments/{td_tournament.id}/shifts/{created['id']}/").status_code == 204 + + +def test_delete_shift_attached_to_two_events_detaches_both(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + event1 = _make_event(client, td_tournament.id, name="Boomilever") + event2 = _make_event(client, td_tournament.id, name="Hovercraft") + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event1['id']}/shifts/{shift['id']}/" + ).status_code == 201 + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event2['id']}/shifts/{shift['id']}/" + ).status_code == 201 + + assert client.delete(f"/tournaments/{td_tournament.id}/shifts/{shift['id']}/").status_code == 204 + + e1 = client.get(f"/tournaments/{td_tournament.id}/events/{event1['id']}/").json() + e2 = client.get(f"/tournaments/{td_tournament.id}/events/{event2['id']}/").json() + assert e1["shifts"] == [] + assert e2["shifts"] == [] + + +def test_shift_routes_require_manage_events(client, td_user, other_tournament, db): + grant_role(db, other_tournament, td_user, "Volunteer") + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/shifts/").status_code == 403 + + +# --------------------------------------------------------------------------- +# Attach / detach +# --------------------------------------------------------------------------- + +def test_attach_shift_success(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id) + shift = _make_shift(client, td_tournament.id).json() + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/" + ) + assert response.status_code == 201 + updated_event = client.get(f"/tournaments/{td_tournament.id}/events/{event['id']}/").json() + assert len(updated_event["shifts"]) == 1 + + +def test_attach_shift_outside_event_bounds_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time=EVENT_DATE + "T08:00:00Z", end_time=EVENT_DATE + "T10:00:00Z") + shift = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T09:00:00Z", end=EVENT_DATE + "T11:00:00Z").json() + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/" + ) + assert response.status_code == 409 + + +def test_attach_shift_event_missing_times_rejected(client, td_user, td_tournament): + """Event start_time/end_time are nullable at create time — but a shift + can't be bounds-checked against an event that has neither set.""" + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time=None, end_time=None) + shift = _make_shift(client, td_tournament.id).json() + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/" + ) + assert response.status_code == 409 + + +def test_attach_overlapping_shift_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time=EVENT_DATE + "T08:00:00Z", end_time=EVENT_DATE + "T16:00:00Z") + shift1 = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T08:00:00Z", end=EVENT_DATE + "T12:00:00Z").json() + shift2 = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T11:00:00Z", end=EVENT_DATE + "T15:00:00Z").json() + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift1['id']}/" + ).status_code == 201 + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift2['id']}/" + ) + assert response.status_code == 409 + + +def test_attach_adjacent_shift_succeeds(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id, start_time=EVENT_DATE + "T08:00:00Z", end_time=EVENT_DATE + "T16:00:00Z") + shift1 = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T08:00:00Z", end=EVENT_DATE + "T12:00:00Z").json() + shift2 = _make_shift(client, td_tournament.id, start=EVENT_DATE + "T12:00:00Z", end=EVENT_DATE + "T16:00:00Z").json() + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift1['id']}/" + ).status_code == 201 + response = client.post( + f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift2['id']}/" + ) + assert response.status_code == 201 + + +def test_same_shift_attached_to_multiple_events_succeeds(client, td_user, td_tournament): + """Reuse case — a shift isn't scoped to a single event.""" + login(client, "td@test.com", "tdpass") + shift = _make_shift(client, td_tournament.id).json() + event1 = _make_event(client, td_tournament.id, name="Boomilever") + event2 = _make_event(client, td_tournament.id, name="Hovercraft") + + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event1['id']}/shifts/{shift['id']}/" + ).status_code == 201 + assert client.post( + f"/tournaments/{td_tournament.id}/events/{event2['id']}/shifts/{shift['id']}/" + ).status_code == 201 + + +def test_detach_shift(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + event = _make_event(client, td_tournament.id) + shift = _make_shift(client, td_tournament.id).json() + client.post(f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/") + + response = client.delete(f"/tournaments/{td_tournament.id}/events/{event['id']}/shifts/{shift['id']}/") + assert response.status_code == 204 + + updated_event = client.get(f"/tournaments/{td_tournament.id}/events/{event['id']}/").json() + assert updated_event["shifts"] == [] diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8565bfcf..d7c309ad 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -68,6 +68,7 @@ def future_date(month: int, day: int) -> str: "state": "Southern California", "level": "invitational", "division": ["B", "C"], + "timezone": "America/Los_Angeles", } test_engine = create_engine(TEST_DATABASE_URL, echo=False) @@ -165,6 +166,7 @@ def _make_tournament_with_td(db: Session, owner: User, name: str) -> Tournament: state="Southern California", level="invitational", division=["B", "C"], + timezone="America/Los_Angeles", ) db.add(tournament) db.flush() diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx index 0811e182..de21e3d1 100644 --- a/frontend/app/dashboard/page.tsx +++ b/frontend/app/dashboard/page.tsx @@ -2,9 +2,9 @@ import { useEffect, useState } from "react" import { useRouter } from "next/navigation" -import { tournamentsApi, tournamentEventsApi, membershipsApi, Tournament, UserMeSlim, authApi, ApiError } from "@/lib/api" +import { tournamentsApi, Tournament, TournamentSummary, UserMeSlim, authApi, ApiError } from "@/lib/api" import { NewTournamentModal } from "@/components/tournament/NewTournamentModal" -import { TournamentCard, CardCounts } from "@/components/tournament/TournamentCard" +import { TournamentCard } from "@/components/tournament/TournamentCard" import { Topbar } from "@/components/layout/Topbar" import { PageHeader } from "@/components/ui/PageHeader" import { Banner, BannerProps } from "@/components/ui/Banner" @@ -28,8 +28,7 @@ interface BannerRule extends Omit { export default function DashboardPage() { const router = useRouter() - const [tournaments, setTournaments] = useState([]) - const [counts, setCounts] = useState>({}) + const [tournaments, setTournaments] = useState([]) const [loading, setLoading] = useState>({"page": true}) const [showModal, setShowModal] = useState(false) @@ -92,22 +91,14 @@ export default function DashboardPage() { useEffect(() => { - tournamentsApi.list().then((data) => { - setTournaments(data) - data.forEach((t) => { - Promise.all([ - tournamentEventsApi.list(t.id).then((e) => e.length).catch(() => 0), - membershipsApi.list(t.id).then((m) => m.length).catch(() => 0), - ]).then(([events, volunteers]) => { - setCounts((prev) => ({ ...prev, [t.id]: { events, volunteers } })) - }) - }) - }).catch(() => {}).finally(() => setLoading(l => ({...l, "page": false}))) + tournamentsApi.list() + .then(setTournaments) + .catch(() => {}) + .finally(() => setLoading(l => ({...l, "page": false}))) }, []) function handleCreated(t: Tournament) { - setTournaments((prev) => [...prev, t]) - setCounts((prev) => ({ ...prev, [t.id]: { events: 0, volunteers: 0 } })) + setTournaments((prev) => [...prev, { ...t, event_count: 0, volunteer_count: 0 }]) setShowModal(false) router.push(`/dashboard/tournaments/${t.id}/overview`) } @@ -169,7 +160,6 @@ export default function DashboardPage() { {tournaments.map((t) => ( router.push(`/dashboard/tournaments/${t.id}/overview`)} /> ))} diff --git a/frontend/app/dashboard/tournaments/[id]/events/page.tsx b/frontend/app/dashboard/tournaments/[id]/events/page.tsx index 494b3a42..1ec8aee7 100644 --- a/frontend/app/dashboard/tournaments/[id]/events/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/events/page.tsx @@ -1,12 +1,76 @@ -// events/page.tsx "use client"; + +import { useState } from "react"; +import { useParams } from "next/navigation"; +import { useAuth } from "@/lib/useAuth"; +import { useMyMembership } from "@/lib/useMyMembership"; +import { useUnsavedChanges } from "@/lib/useUnsavedChanges"; +import { PageHeader } from "@/components/ui/PageHeader"; +import { Card } from "@/components/ui/Card"; +import { Spinner } from "@/components/ui/Spinner"; +import { EmptyState } from "@/components/ui/EmptyState"; +import { TabStrip } from "@/components/ui/TabStrip"; +import { IconLock } from "@/components/ui/Icons"; +import { EventsTab } from "@/components/tournament/events/EventsTab"; +import { ShiftsTab } from "@/components/tournament/events/ShiftsTab"; + +type EventsPageTab = "events" | "shifts"; + export default function EventsPage() { + const params = useParams(); + const tournamentId = Number(params.id); + + const { user: currentUser } = useAuth(); + const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); + const { guard } = useUnsavedChanges(); + + const [activeTab, setActiveTab] = useState("events"); + + const isAdmin = currentUser?.role === "admin"; + const isOwner = !!membership?.is_owner; + const canManageEvents = isAdmin || isOwner || hasPermission("manage_events"); + + if (membershipLoading) { + return ( +
+ +
+ ); + } + + if (!canManageEvents) { + return ( +
+ + + } + title="No access" + description="You need the manage events permission to view this page." + /> + +
+ ); + } + return (
-

Events

-

- Events table — coming soon. -

+ + + guard(() => setActiveTab(tab))} + /> + + {activeTab === "events" ? ( + + ) : ( + + )}
); -} \ No newline at end of file +} diff --git a/frontend/app/dashboard/tournaments/[id]/layout.tsx b/frontend/app/dashboard/tournaments/[id]/layout.tsx index 70db183b..fe2fa697 100644 --- a/frontend/app/dashboard/tournaments/[id]/layout.tsx +++ b/frontend/app/dashboard/tournaments/[id]/layout.tsx @@ -2,13 +2,90 @@ import { ReactNode, useState, useEffect } from "react"; import { use } from "react"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; import { TournamentProvider, useTournament } from "@/lib/useTournament"; import { MyMembershipProvider } from "@/lib/useMyMembership"; import { UnsavedChangesProvider } from "@/lib/useUnsavedChanges"; +import { LayoutPanelProvider, useLayoutPanelContent, LayoutPanel } from "@/lib/useLayoutPanel"; import { Sidebar, COLLAPSED_W, EXPANDED_W } from "@/components/layout/Sidebar"; import { Topbar } from "@/components/layout/Topbar"; -import { tournamentsApi } from "@/lib/api"; +import { Button } from "@/components/ui/Button"; +import { IconWarning } from "@/components/ui/Icons"; +import { tournamentsApi, ApiError } from "@/lib/api"; + +function TournamentNotFound() { + const router = useRouter(); + return ( +
+
+ +
+

+ Tournament not found +

+

+ It may have been deleted, or you may not have access to it. +

+
+ +
+
+ ); +} + +// Renders whatever a descendant registered via useSetLayoutPanel as a sibling +// of the Topbar+main column, so it consumes layout width instead of covering +// the page. +// +// mounted lags behind panel on close (stays mounted through the collapse +// transition instead of vanishing instantly); expanded flips a frame after +// mount so there's an actual 0 -> full-width transition to animate rather +// than appearing already-open. Same technique as ShiftsTab's own split-view +// panel (panelMountedId/panelExpanded there). +function LayoutPanelSlot() { + const panel = useLayoutPanelContent(); + const [mounted, setMounted] = useState(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (panel) { + setMounted(panel); + // A single rAF often fires before the browser has actually painted + // the just-mounted width:0 state, so the width:full flip lands in the + // same paint and there's nothing to visibly transition from. Nesting + // a second rAF guarantees one real paint happens in between. + let raf2 = 0; + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => setExpanded(true)); + }); + return () => { cancelAnimationFrame(raf1); cancelAnimationFrame(raf2); }; + } + setExpanded(false); + }, [panel]); + + if (!mounted) return null; + + return ( +
{ if (!expanded) setMounted(null); }} + style={{ + width: expanded ? mounted.width : 0, + opacity: expanded ? 1 : 0, + flexShrink: 0, overflow: "hidden", height: "100%", + transition: "width 220ms ease, opacity 200ms ease", + }} + > +
+ {mounted.content} +
+
+ ); +} function TournamentShell({ tournamentId, @@ -18,6 +95,7 @@ function TournamentShell({ children: ReactNode; }) { const [sidebarExpanded, setSidebarExpanded] = useState(false); + const [notFound, setNotFound] = useState(false); const { setSelectedTournament } = useTournament(); const pathname = usePathname(); // Sidebar is locked open (not just hover-expanded) on settings routes — @@ -25,9 +103,21 @@ function TournamentShell({ const onSettingsRoute = pathname.startsWith(`/dashboard/tournaments/${tournamentId}/settings`); useEffect(() => { - tournamentsApi.get(Number(tournamentId)).then(setSelectedTournament).catch(console.error); + tournamentsApi.get(Number(tournamentId)) + .then(setSelectedTournament) + .catch((err) => { + if (err instanceof ApiError && err.status === 404) { + setNotFound(true); + } else { + console.error(err); + } + }); }, [tournamentId, setSelectedTournament]); + if (notFound) { + return ; + } + return (
+ {/* Third flex sibling, not an overlay: it shrinks the column above + (Topbar included) instead of covering it, so the page stays live. */} + ); } @@ -63,9 +156,11 @@ export default function TournamentLayout({ {/* Above the shell so Sidebar/Topbar can read the dirty flag a nested page (e.g. the roles editor) registers. */} - - {children} - + + + {children} + + diff --git a/frontend/app/dashboard/tournaments/[id]/members/page.tsx b/frontend/app/dashboard/tournaments/[id]/members/page.tsx index a5795dcb..f73ea225 100644 --- a/frontend/app/dashboard/tournaments/[id]/members/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/members/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "next/navigation"; import { membershipsApi, rolesApi, MembershipSlim, Role, ApiError } from "@/lib/api"; import { formatPhone } from "@/lib/auth"; @@ -9,6 +9,8 @@ import { STATUS_VARIANT } from "@/lib/membershipDisplay"; import { useAuth } from "@/lib/useAuth"; import { useTournament } from "@/lib/useTournament"; import { useMemberRoleLock } from "@/lib/roles/useMemberRoleLock"; +import { useSetLayoutPanel } from "@/lib/useLayoutPanel"; +import { usePanelSelection } from "@/lib/usePanelSelection"; import { PageHeader } from "@/components/ui/PageHeader"; import { Card } from "@/components/ui/Card"; import { Badge } from "@/components/ui/Badge"; @@ -19,15 +21,33 @@ import { AvatarCircle } from "@/components/ui/AvatarCircle"; import { Tooltip } from "@/components/ui/Tooltip"; import { Input } from "@/components/ui/Input"; import { Dropdown } from "@/components/ui/Dropdown"; +import { Checkbox } from "@/components/ui/Checkbox"; import { RolesCell } from "@/components/tournament/RolesCell"; import { JoinMethodCell } from "@/components/tournament/JoinMethodCell"; -import { MemberPanel } from "@/components/tournament/MemberPanel"; +import { MemberPanel, MEMBER_PANEL_WIDTH } from "@/components/tournament/MemberPanel"; +import { MassRoleEditor, MASS_ROLE_EDITOR_WIDTH } from "@/components/tournament/MassRoleEditor"; import { RemoveMemberModal } from "@/components/tournament/RemoveMemberModal"; import { SelfRemoveRedirectModal } from "@/components/tournament/SelfRemoveRedirectModal"; -import { IconLock, IconSearch, IconArrowDown, IconExpand, IconTrash } from "@/components/ui/Icons"; +import { SelectionBar } from "@/components/ui/SelectionBar"; +import { emptyFilterState } from "@/components/ui/FilterModal"; +import { usePersistedFilter } from "@/lib/usePersistedFilter"; +import { MembersFilterModal, isMembersFilterActive, MEMBERS_FILTER_KEYS } from "@/components/tournament/MembersFilterModal"; +import { IconLock, IconSearch, IconArrowDown, IconExpand, IconTrash, IconMembers, IconFilter, IconX } from "@/components/ui/Icons"; // Name / Email / Phone / Account Age / Join Date / Join Method / Status / Roles / Actions const MEMBER_ROW_COLUMNS = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 2.6fr 70px"; +// Roles dropped — the fr tracks below just absorb its share automatically. +const MEMBER_ROW_COLUMNS_COMPACT = "0.8fr 1.2fr 0.6fr 90px 90px 110px 90px 70px"; +// Always present as a grid track (never conditionally added/removed) so its +// width can transition between 0 and full instead of popping in — animating +// grid-template-columns only works when the track count stays constant. +const SELECT_COLUMN_WIDTH = "28px"; +function memberColumns(selectMode: boolean, panelOpen: boolean) { + const rest = panelOpen ? MEMBER_ROW_COLUMNS_COMPACT : MEMBER_ROW_COLUMNS; + return `${selectMode ? SELECT_COLUMN_WIDTH : "0px"} ${rest}`; +} + +const DIRTY_TITLE = "Save or discard your changes first"; type SortField = "first_name" | "last_name" | "joined" | "account_age"; type SortDir = "asc" | "desc"; @@ -40,7 +60,6 @@ const SORT_FIELD_OPTIONS = [ ]; const STATUS_FILTER_OPTIONS = [ - { value: "all", label: "All statuses" }, { value: "interested", label: "Interested" }, { value: "confirmed", label: "Confirmed" }, ]; @@ -76,7 +95,8 @@ function DurationCell({ iso }: { iso: string }) { } function MemberRow({ - tournamentId, membership, allRoles, canTouchRole, locked, isSelf, isArchived, onUpdated, onExpand, onRemove, onSelfRemove, isLast, + tournamentId, membership, allRoles, canTouchRole, locked, isSelf, isArchived, onUpdated, onFocus, onRemove, onSelfRemove, isLast, + selectMode, selected, selectionLocked, onToggleSelect, focusActive, focused, rolesReadOnly, panelOpen, }: { tournamentId: number; membership: MembershipSlim; @@ -89,27 +109,61 @@ function MemberRow({ /** Archived tournaments hide the remove control entirely rather than showing it disabled. */ isArchived: boolean; onUpdated: (updated: MembershipSlim) => void; - onExpand: (membershipId: number) => void; + onFocus: () => void; onRemove: (membership: MembershipSlim) => void; onSelfRemove: (membership: MembershipSlim) => void; isLast: boolean; + selectMode: boolean; + selected: boolean; + /** Open panel has unsaved changes — switching focus/selection is frozen until it resolves. */ + selectionLocked: boolean; + onToggleSelect: () => void; + /** A single-member panel is open (for some row, not necessarily this one) — rows become click-to-switch instead of inert. */ + focusActive: boolean; + /** This row is the one currently shown in the single-member panel. */ + focused: boolean; + /** This row's roles are open in the docked panel — don't offer a second, inline way to edit the same thing. */ + rolesReadOnly: boolean; + /** Any docked panel is open — the table is narrower, so the Roles column drops out to give the rest room. */ + panelOpen: boolean; }) { const [hovered, setHovered] = useState(false); const { user } = membership; const name = `${user.first_name ?? ""} ${user.last_name ?? ""}`.trim() || "—"; + // Two different reasons a row might be clickable: toggling a checkbox in + // Select mode, or switching which row the single-member panel shows. Never + // both at once — the two flows are mutually exclusive. + const clickable = (selectMode || focusActive) && !selectionLocked; + const handleRowClick = selectMode ? onToggleSelect : onFocus; + const highlighted = selectMode ? selected : focused; + const lockedTitle = selectionLocked ? DIRTY_TITLE : undefined; + return (
setHovered(true)} onMouseLeave={() => setHovered(false)} + onClick={clickable ? handleRowClick : undefined} + title={(selectMode || focusActive) ? lockedTitle : undefined} style={{ - display: "grid", gridTemplateColumns: MEMBER_ROW_COLUMNS, alignItems: "center", + display: "grid", gridTemplateColumns: memberColumns(selectMode, panelOpen), alignItems: "center", gap: "10px", padding: "10px 12px", borderBottom: isLast ? "none" : "1px solid var(--color-border)", - background: hovered ? "var(--color-bg)" : "transparent", - transition: "background 100ms ease", + background: highlighted ? "var(--color-bg)" : hovered ? "var(--color-bg)" : "transparent", + transition: "background 100ms ease, grid-template-columns 200ms ease", + cursor: clickable ? "pointer" : selectionLocked ? "not-allowed" : "default", }} > + e.stopPropagation()} + > + +
{membership.status} - -
+ {!panelOpen && ( + // Stops row clicks (select toggle / focus switch) from firing when + // the intent was to pick a role chip. +
e.stopPropagation()} style={{ minWidth: 0 }}> + +
+ )} +
e.stopPropagation()}> {!isArchived && ( )} @@ -178,15 +240,33 @@ export default function MembersPage() { const [loadError, setLoadError] = useState(null); const [search, setSearch] = useState(""); - const [roleFilter, setRoleFilter] = useState("all"); - const [statusFilter, setStatusFilter] = useState("all"); + // Committed filters only — the modal keeps its own draft until Apply. + const [filters, applyFilters] = usePersistedFilter("members", currentUser?.id, tournamentId, MEMBERS_FILTER_KEYS); + const [showFilterModal, setShowFilterModal] = useState(false); const [sortField, setSortField] = useState("joined"); const [sortDir, setSortDir] = useState("desc"); - const [expandedId, setExpandedId] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); const [selfRemoveTarget, setSelfRemoveTarget] = useState(null); + // The two mutually-exclusive panel flows ("Expand" a single member vs. + // Select mode) and the dirty gate that freezes both — shared with the + // Events tab. No "creating new" flow here: members join via invite/join + // code, so there's nothing to create from this page. + const { + focusedId, selectMode, selectedIds, massPanelOpen, panelDirty, + setPanelDirty, focusItem, toggleSelectMode, toggleSelected, toggleSelectAll, + openMassPanel, clearFocus, clearSelection, forgetItem, getPrevNext, + } = usePanelSelection(); + + // useMemberRoleLock hands back fresh closures every render, which would + // re-register the docked panel in a loop if they went straight into the + // effect's deps. These wrappers are stable and read the latest ones. + const roleLockRef = useRef({ canTouchRole, canEditMember }); + useEffect(() => { roleLockRef.current = { canTouchRole, canEditMember }; }); + const canTouchRoleStable = useCallback((role: Role) => roleLockRef.current.canTouchRole(role), []); + const canEditMemberStable = useCallback((target: MembershipSlim) => roleLockRef.current.canEditMember(target), []); + useEffect(() => { if (!canManageMembers) return; membershipsApi.list(tournamentId) @@ -200,8 +280,14 @@ export default function MembersPage() { const q = search.trim().toLowerCase(); const filtered = members.filter((m) => { if (q && !memberName(m).toLowerCase().includes(q) && !m.user.email.toLowerCase().includes(q)) return false; - if (roleFilter !== "all" && !m.roles.some((r) => String(r.id) === roleFilter)) return false; - if (statusFilter !== "all" && m.status !== statusFilter) return false; + // A member is hidden only when *every* role they hold is excluded — + // otherwise someone with a kept role would vanish for holding an + // unrelated excluded one. A member with no roles at all is never + // hidden by this filter — deselecting every role should surface the + // unassigned members, not hide them along with everyone else. + const roleKeys = m.roles.map((r) => String(r.id)); + if (roleKeys.length > 0 && roleKeys.every((k) => filters.role.has(k))) return false; + if (filters.status.has(m.status)) return false; return true; }); const sorted = [...filtered].sort((a, b) => { @@ -211,11 +297,109 @@ export default function MembersPage() { return sortDir === "asc" ? cmp : -cmp; }); return sorted; - }, [members, search, roleFilter, statusFilter, sortField, sortDir]); + }, [members, search, filters, sortField, sortDir]); + + const roleFilterOptions = useMemo( + () => allRoles.map((r) => ({ value: String(r.id), label: r.label })), + [allRoles] + ); - function handleMemberUpdated(updated: MembershipSlim) { + const handleMemberUpdated = useCallback((updated: MembershipSlim) => { setMembers((prev) => prev && prev.map((m) => (m.id === updated.id ? updated : m))); - } + }, []); + + // Either flow narrows the table for a docked panel — drop the Roles + // column to give the rest more room while it's up. + const panelOpen = focusedId !== null || massPanelOpen; + + // Steps through the table's own current filter/sort order, so switching + // sort or narrowing a filter mid-edit still lands somewhere sensible. + const { hasPrev, hasNext, prevId, nextId } = getPrevNext(visibleMembers, (m) => m.id); + + // Only meaningful for the Select-mode flow — the panel there only opens + // once "Edit" is pressed in the SelectionBar, not as soon as one row is + // checked (see massPanelOpen). + const selectedMembers = useMemo( + () => (members ?? []).filter((m) => selectedIds.has(m.id)), + [members, selectedIds] + ); + + const { setPanel, clearPanel } = useSetLayoutPanel(); + + // The panels don't render here — they're pushed into the layout shell's + // docked slot so the panel is a *sibling* of
and shrinks it, leaving + // the table clickable. Re-runs whenever anything the panel is built from + // changes, so the element never closes over stale props. + useEffect(() => { + if (focusedId !== null) { + const membership = (members ?? []).find((m) => m.id === focusedId); + if (!membership) { clearFocus(); return; } + setPanel( + prevId !== null && focusItem(prevId)} + onNext={() => nextId !== null && focusItem(nextId)} + hasPrev={hasPrev} + hasNext={hasNext} + />, + MEMBER_PANEL_WIDTH, + ); + return; + } + + // One row checked in Select mode gets the same detail panel the focus + // flow shows — the mass editor would be a worse view of a single member. + if (massPanelOpen && selectedMembers.length === 1) { + setPanel( + , + MEMBER_PANEL_WIDTH, + ); + return; + } + + if (massPanelOpen && selectedMembers.length > 1) { + setPanel( + , + MASS_ROLE_EDITOR_WIDTH, + ); + return; + } + + clearPanel(); + }, [ + focusedId, members, massPanelOpen, selectedMembers, tournamentId, allRoles, + prevId, nextId, hasPrev, hasNext, focusItem, setPanelDirty, + canTouchRoleStable, canEditMemberStable, handleMemberUpdated, + clearFocus, clearSelection, setPanel, clearPanel, + ]); + + // Unmount only (e.g. navigating off the Members page) — clearing in the + // effect above's cleanup instead would tear the panel down on every re-run. + useEffect(() => () => clearPanel(), [clearPanel]); if (membershipLoading) { return ( @@ -251,7 +435,7 @@ export default function MembersPage() { ); } - const isFiltered = search.trim() !== "" || roleFilter !== "all" || statusFilter !== "all"; + const isFiltered = search.trim() !== "" || isMembersFilterActive(filters); return (
@@ -265,7 +449,11 @@ export default function MembersPage() { {members.length === 0 ? ( - + } + title="No members yet" + description="Members who join this tournament will show up here." + /> ) : ( <> @@ -283,24 +471,20 @@ export default function MembersPage() { fullWidth />
- ({ value: String(r.id), label: r.label }))]} - size="md" - variant="secondary" - width={170} - /> - + + {isMembersFilterActive(filters) && ( + + )} + {canManageMembers && !isArchived && ( + + )}
+ + 0 && visibleMembers.every((m) => selectedIds.has(m.id))} + locked={panelDirty} + onChange={(checked) => toggleSelectAll(visibleMembers.map((m) => m.id), checked)} + /> + Members — {isFiltered ? `${visibleMembers.length} of ${members.length}` : members.length} Email Phone @@ -333,7 +542,7 @@ export default function MembersPage() { Joined Method Status - Roles + {!panelOpen && Roles} Actions
@@ -351,10 +560,21 @@ export default function MembersPage() { isSelf={currentUser?.id === m.user.id} isArchived={isArchived} onUpdated={handleMemberUpdated} - onExpand={setExpandedId} + onFocus={() => focusItem(m.id)} onRemove={setRemoveTarget} onSelfRemove={setSelfRemoveTarget} isLast={i === visibleMembers.length - 1} + selectMode={selectMode} + selected={selectedIds.has(m.id)} + selectionLocked={panelDirty} + onToggleSelect={() => toggleSelected(m.id)} + focusActive={focusedId !== null} + focused={focusedId === m.id} + // Whichever row the open panel is editing shows its roles + // read-only here, so the same roles can't be edited from + // two places at once. + rolesReadOnly={focusedId === m.id || (massPanelOpen && selectedIds.has(m.id))} + panelOpen={panelOpen} /> )) )} @@ -362,18 +582,25 @@ export default function MembersPage() { )} - {expandedId !== null && ( - setExpandedId(null)} - onUpdated={handleMemberUpdated} + {showFilterModal && ( + setShowFilterModal(false)} /> )} + {/* Stays up through the whole "checking boxes" phase — the panel only + opens once Edit is pressed here, not as soon as one row is checked. */} + + {removeTarget && ( setRemoveTarget(null)} onRemoved={() => { setMembers((prev) => prev && prev.filter((m) => m.id !== removeTarget.id)); + // Otherwise a removed-but-still-selected/focused row would keep a + // panel open against a member who no longer exists. + forgetItem(removeTarget.id); setRemoveTarget(null); }} /> diff --git a/frontend/app/dashboard/tournaments/[id]/settings/general/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/general/page.tsx index fba09a63..e1efa7be 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/general/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/general/page.tsx @@ -63,7 +63,7 @@ export default function GeneralSettingsPage() { const router = useRouter(); const tournamentId = Number(params.id); const { user: currentUser } = useAuth(); - const { selectedTournament, setSelectedTournament, isArchived, refresh } = useTournament(); + const { selectedTournament, setSelectedTournament, isArchived } = useTournament(); const { membership, hasPermission, loading: membershipLoading } = useMyMembership(); const [draft, setDraft] = useState(null); @@ -127,15 +127,23 @@ export default function GeneralSettingsPage() { setSaveError(undefined); setErrors({}); - if (!draft.name.trim()) { setErrors((e) => ({ ...e, name: "Cannot be empty." })); setSaving(false); return; } - if (/\d/.test(draft.name)) { setErrors((e) => ({ ...e, name: "Name must not contain numbers." })); setSaving(false); return; } - if (!draft.university_id && !draft.location.trim()) { setSaveError("Location is required."); setSaving(false); return; } - if (!draft.start_date || !draft.end_date) { setSaveError("Start and end date are required."); setSaving(false); return; } - if (draft.start_date < new Date().toISOString().slice(0, 10)) { setSaveError("Start date cannot be in the past."); setSaving(false); return; } - if (draft.end_date < draft.start_date) { setSaveError("End date cannot be before start date."); setSaving(false); return; } - if (!draft.state) { setSaveError("State is required — pick one from the list."); setSaving(false); return; } - if (!draft.level) { setSaveError("Level is required — pick one from the list."); setSaving(false); return; } - if (draft.division.length === 0) { setSaveError("Select at least one division."); setSaving(false); return; } + const fieldErrors: Record = {}; + if (!draft.name.trim()) fieldErrors.name = "Cannot be empty."; + else if (/\d/.test(draft.name)) fieldErrors.name = "Name must not contain numbers."; + if (!draft.university_id && !draft.location.trim()) fieldErrors.location = "Location is required."; + if (!draft.start_date) fieldErrors.start_date = "Start date is required."; + else if (draft.start_date < new Date().toISOString().slice(0, 10)) fieldErrors.start_date = "Cannot be in the past."; + if (!draft.end_date) fieldErrors.end_date = "End date is required."; + else if (draft.end_date < draft.start_date) fieldErrors.end_date = "Cannot be before start date."; + if (!draft.state) fieldErrors.state = "Pick one from the list."; + if (!draft.level) fieldErrors.level = "Pick one from the list."; + if (draft.division.length === 0) fieldErrors.division = "Select at least one division."; + + if (Object.keys(fieldErrors).length > 0 || !draft.state || !draft.level) { + setErrors(fieldErrors); + setSaving(false); + return; + } // Explicit nulls clear whichever field isn't the active source — the // backend now applies both atomically (see models.py's before_flush check). @@ -206,6 +214,7 @@ export default function GeneralSettingsPage() { onChange={(text, matched) => setDraft((d) => d && { ...d, location: text, university_id: matched?.id ?? null })} placeholder="e.g. USC" locked={isArchived} + error={errors.location} /> @@ -218,6 +227,7 @@ export default function GeneralSettingsPage() { min={new Date().toISOString().slice(0, 10)} value={draft.start_date} onChange={(e) => setDraft((d) => d && { ...d, start_date: e.target.value })} + error={errors.start_date} /> setDraft((d) => d && { ...d, end_date: e.target.value })} + error={errors.end_date} />
@@ -238,6 +249,7 @@ export default function GeneralSettingsPage() { value={stateText} onChange={(text, matched) => { setStateText(text); setDraft((d) => d && { ...d, state: matched ?? "" }); }} locked={isArchived} + error={errors.state} /> @@ -249,6 +261,7 @@ export default function GeneralSettingsPage() { value={levelText} onChange={(text, matched) => { setLevelText(text); setDraft((d) => d && { ...d, level: matched?.value ?? "" }); }} locked={isArchived} + error={errors.level} /> @@ -258,6 +271,11 @@ export default function GeneralSettingsPage() { onChange={(v) => toggleDivision(v as TournamentDivision)} locked={isArchived} /> + {errors.division && ( +

+ {errors.division} +

+ )}
)} @@ -404,7 +422,7 @@ export default function GeneralSettingsPage() { tournamentName={selectedTournament.name} mode={selectedTournament.is_archived ? "unarchive" : "archive"} onClose={() => setShowArchiveModal(false)} - onDone={() => { refresh(); setShowArchiveModal(false); }} + onDone={(updated) => { setSelectedTournament(updated); setShowArchiveModal(false); }} /> )} diff --git a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx index 3b421803..072a6868 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/invites/page.tsx @@ -15,7 +15,7 @@ import { Spinner } from "@/components/ui/Spinner"; import { EmptyState } from "@/components/ui/EmptyState"; import { AvatarCircle } from "@/components/ui/AvatarCircle"; import { HoverCard } from "@/components/ui/HoverCard"; -import { IconArchive, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; +import { IconArchive, IconInvite, IconLock, IconPlus, IconTrash } from "@/components/ui/Icons"; import { CreateInviteModal } from "@/components/tournament/settings/CreateInviteModal"; import { AddTimePopover } from "@/components/tournament/settings/AddTimePopover"; import { personUser, personName, personRoles } from "@/lib/personDisplay"; @@ -352,6 +352,7 @@ export default function InvitesSettingsPage() { ) : invites.length === 0 ? ( } title="No invites yet" description="Create an invite to let members or staff join this tournament." action={ diff --git a/frontend/app/dashboard/tournaments/[id]/settings/roles/edit/page.tsx b/frontend/app/dashboard/tournaments/[id]/settings/roles/edit/page.tsx index 5493663a..33360501 100644 --- a/frontend/app/dashboard/tournaments/[id]/settings/roles/edit/page.tsx +++ b/frontend/app/dashboard/tournaments/[id]/settings/roles/edit/page.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/Button"; import { Spinner } from "@/components/ui/Spinner"; import { Tooltip } from "@/components/ui/Tooltip"; import { FloatingSaveBar } from "@/components/ui/FloatingSaveBar"; +import { TabStrip } from "@/components/ui/TabStrip"; import { RoleDropDivider } from "@/components/tournament/settings/RoleDropDivider"; import { RoleMembersTab } from "@/components/tournament/settings/RoleMembersTab"; import { RoleDraft, RoleEditorForm } from "@/components/tournament/settings/RoleEditorForm"; @@ -354,24 +355,14 @@ export default function RoleEditorPage() {
{activeRole ? ( <> -
- {tabs.map((tab) => ( - - ))} -
+ ({ + key: tab, + label: tab === "details" ? "Details" : `Members — ${memberCounts[activeRole.id] ?? 0}`, + }))} + activeKey={activeTab} + onChange={setActiveTab} + /> {activeTab === "details" || activeIsTemp ? ( void; + children: ReactNode; + width?: number; + /** + * Rendered below the scrollable content, e.g. a FloatingSaveBar — scoped + * to this panel's box (via a containing-block trick) so it centers on the + * panel instead of the viewport. Deliberately narrow: only this slot gets + * scoped, not all of `children` — anything else position:fixed inside + * `children` (a Popover, a Modal) still needs real viewport coordinates, + * since its own position math (getBoundingClientRect) is viewport-relative + * and would land off-screen if it inherited this panel's box too. + */ + footer?: ReactNode; + /** Prev/next controls in the header, left of the close button — e.g. stepping through a table's current filtered/sorted order. Omit both to hide the controls entirely. */ + onPrev?: () => void; + onNext?: () => void; + prevDisabled?: boolean; + nextDisabled?: boolean; +} + +// Modal-free side panel: no portal, no backdrop, no overlay. It's meant to be rendered as a plain flex child of the app shell's +// top-level row (see useLayoutPanel), so it *takes* horizontal space rather +// than covering it — the page beside it stays fully interactive, which a +// modal overlay can't offer. +export function DockedPanel({ + onClose, children, width = 480, footer, onPrev, onNext, prevDisabled, nextDisabled, +}: DockedPanelProps) { + const showNav = onPrev !== undefined || onNext !== undefined; + useEffect(() => { + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [onClose]); + + return ( +
+ {/* Same height/background as Topbar so this strip's bottom border lines + up with Topbar's and the two read as one continuous bar. */} +
+ {showNav ? ( +
+ + +
+ ) : } + +
+
+ {children} +
+ {footer && ( + // Only this slot gets the containing-block trick — see the prop doc + // above for why it can't be the whole panel. +
+ {footer} +
+ )} +
+ ); +} diff --git a/frontend/components/layout/Sidebar.tsx b/frontend/components/layout/Sidebar.tsx index 0ac9f9af..0f3bcd08 100644 --- a/frontend/components/layout/Sidebar.tsx +++ b/frontend/components/layout/Sidebar.tsx @@ -52,13 +52,18 @@ export function Sidebar({ onExpandedChange, tournamentId }: SidebarProps) { const canManageInvites = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_invites"); const canManageTournament = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_tournament"); const canManageMembers = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_members"); + const canManageEvents = currentUser?.role === "admin" || !!membership?.is_owner || hasPermission("manage_events"); const settingsSubitems = SETTINGS_SUBITEMS.filter( ({ segment }) => (segment !== "roles" || canManageRoles) && (segment !== "invites" || canManageInvites) && (segment !== "audit-log" || canManageTournament) ); - const navItems = NAV_ITEMS.filter(({ segment }) => segment !== "members" || canManageMembers); + const navItems = NAV_ITEMS.filter( + ({ segment }) => + (segment !== "members" || canManageMembers) && + (segment !== "events" || canManageEvents) + ); // Locked open on settings routes — the sub-nav labels need to stay // readable without requiring the mouse to stay parked on the rail. const expanded = hovered || onSettingsRoute; diff --git a/frontend/components/layout/Topbar.tsx b/frontend/components/layout/Topbar.tsx index 60d13491..27ef9334 100644 --- a/frontend/components/layout/Topbar.tsx +++ b/frontend/components/layout/Topbar.tsx @@ -4,14 +4,17 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTournament } from "@/lib/useTournament"; import { useUnsavedChanges } from "@/lib/useUnsavedChanges"; -import { Tournament } from "@/lib/api"; +import { Tournament, TournamentPublic } from "@/lib/api"; import { parseLocalDate } from "@/lib/date"; import { NewTournamentModal } from "@/components/tournament/NewTournamentModal"; import { UserAvatar } from "@/components/ui/UserAvatar"; import { Dropdown } from "@/components/ui/Dropdown"; import { IconPlus } from "@/components/ui/Icons"; import { COLLAPSED_W, EXPANDED_W } from "@/components/layout/Sidebar"; -import Link from "next/link"; + +// Shared with DockedPanel so its own header strip lines up exactly with +// Topbar's bottom border, reading as one continuous bar across both. +export const TOPBAR_HEIGHT = 52; interface TopbarProps { showWordmark?: boolean; @@ -28,20 +31,22 @@ interface TopbarProps { // Isolated into its own component so useTournament() is only called when // showDropdown=true and a TournamentProvider is present in the tree. -function tournamentDisplayName(t: Tournament) { +function tournamentDisplayName(t: TournamentPublic) { const year = parseLocalDate(t.start_date).getFullYear(); return `${year} ${t.short_name || t.name}`; } function TournamentDropdown({ tournamentId }: { tournamentId?: string | number }) { const router = useRouter(); - const { tournaments, setSelectedTournament, refresh } = useTournament(); + const { tournaments, refresh } = useTournament(); const { guard } = useUnsavedChanges(); const [showNewModal, setShowNewModal] = useState(false); + // No optimistic setSelectedTournament here — the [id]/layout.tsx shell + // refetches the full tournament keyed off the URL id on every navigation, + // which is the sole writer of selectedTournament (see useTournament.tsx). async function handleCreated(t: Tournament) { await refresh(); - setSelectedTournament(t); setShowNewModal(false); router.push(`/dashboard/tournaments/${t.id}/overview`); } @@ -51,7 +56,6 @@ function TournamentDropdown({ tournamentId }: { tournamentId?: string | number } if (!t) return; const segment = window.location.pathname.split("/").pop() ?? "overview"; guard(() => { - setSelectedTournament(t); router.push(`/dashboard/tournaments/${t.id}/${segment}`); }); } @@ -96,7 +100,7 @@ export function Topbar({ return (
{showWordmark && ( - NEXUS - + )} {showDropdown && } diff --git a/frontend/components/profile/ExperienceTables.tsx b/frontend/components/profile/ExperienceTables.tsx index 49419c27..4d74ad6d 100644 --- a/frontend/components/profile/ExperienceTables.tsx +++ b/frontend/components/profile/ExperienceTables.tsx @@ -252,7 +252,7 @@ export function CompetitionExperienceSpreadsheet({ {showHoverControls && ( @@ -461,6 +461,13 @@ export function CompetitionExperienceSpreadsheet({ return (
-
e.stopPropagation()} - > -
- -
-
- {children} -
-
-
, - document.body - ); -} diff --git a/frontend/components/ui/TabStrip.tsx b/frontend/components/ui/TabStrip.tsx new file mode 100644 index 00000000..1f51f3be --- /dev/null +++ b/frontend/components/ui/TabStrip.tsx @@ -0,0 +1,35 @@ +'use client' + +interface TabStripTab { + key: T + label: string +} + +interface TabStripProps { + tabs: TabStripTab[] + activeKey: T + onChange: (key: T) => void +} + +export function TabStrip({ tabs, activeKey, onChange }: TabStripProps) { + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ) +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 79248bb4..56bd00ae 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -78,19 +78,20 @@ export interface CanonicalEvent { category: EventCategory } +// Writes are admin-only, under /admin/ — GET stays public/unauthenticated. export const eventCategoriesApi = { list: () => api.get('/event-categories/'), - create: (body: { name: string }) => api.post('/event-categories/', body), - update: (id: number, body: { name: string }) => api.patch(`/event-categories/${id}/`, body), - delete: (id: number) => api.delete(`/event-categories/${id}/`), + create: (body: { name: string }) => api.post('/admin/event-categories/', body), + update: (id: number, body: { name: string }) => api.patch(`/admin/event-categories/${id}/`, body), + delete: (id: number) => api.delete(`/admin/event-categories/${id}/`), } export const canonicalEventsApi = { list: () => api.get('/events/'), - create: (body: { name: string; category_id: number }) => api.post('/events/', body), + create: (body: { name: string; category_id: number }) => api.post('/admin/events/', body), update: (id: number, body: Partial<{ name: string; category_id: number }>) => - api.patch(`/events/${id}/`, body), - delete: (id: number) => api.delete(`/events/${id}/`), + api.patch(`/admin/events/${id}/`, body), + delete: (id: number) => api.delete(`/admin/events/${id}/`), } // ------------------------------------------------------------------------- @@ -363,8 +364,10 @@ export const TOURNAMENT_STATES = [ ] as const export type TournamentState = typeof TOURNAMENT_STATES[number] -export interface Tournament { - id: number +// Shared fields — mirrors backend TournamentPublic, the base for +// Tournament, TournamentSummary (dashboard list), and JoinPreviewTournament +// (public join preview). +export interface TournamentPublic { name: string short_name: string | null start_date: string @@ -374,8 +377,12 @@ export interface Tournament { state: TournamentState level: TournamentLevel division: TournamentDivision[] - is_public: boolean is_verified: boolean +} + +export interface Tournament extends TournamentPublic { + id: number + is_public: boolean is_archived: boolean owner_id: number roles: Role[] @@ -383,6 +390,17 @@ export interface Tournament { updated_at: string } +// GET /tournaments/me/ — lightweight card view, no roles/owner +export interface TournamentSummary extends TournamentPublic { + id: number + is_public: boolean + is_archived: boolean + event_count: number + volunteer_count: number + created_at: string + updated_at: string +} + // location xor university_id — exactly one required, matches backend TournamentCreate export type TournamentCreate = { name: string @@ -392,6 +410,8 @@ export type TournamentCreate = { state: TournamentState level: TournamentLevel division: TournamentDivision[] + // IANA name — set once from the creator's browser timezone, no update path. + timezone: string is_public?: boolean } & ( | { location: string; university_id?: never } @@ -413,7 +433,7 @@ export interface TournamentUpdate { export const tournamentsApi = { // GET /tournaments/me/ — tournaments the current user has any membership in - list: () => api.get('/tournaments/me/'), + list: () => api.get('/tournaments/me/'), get: (id: number) => api.get(`/tournaments/${id}/`), create: (body: TournamentCreate) => api.post('/tournaments/', body), update: (id: number, body: TournamentUpdate) => api.patch(`/tournaments/${id}/`, body), @@ -435,36 +455,153 @@ export const adminTournamentsApi = { } +// ------------------------------------------------------------------------- +// Tournament Shifts — nested under /tournaments/{id}/shifts/, and attached +// to events via /tournaments/{id}/events/{eventId}/shifts/{shiftId}/ +// ------------------------------------------------------------------------- +export interface TournamentShift { + id: number + tournament_id: number + label: string + start: string + end: string + // How many TournamentEvents this shift is currently attached to — drives + // the "attached to N events" delete-confirm warning. + event_count: number + created_at: string + updated_at: string +} + +export interface TournamentShiftInput { + label: string + start: string + end: string +} + +export const tournamentShiftsApi = { + list: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/shifts/`), + create: (tournamentId: number, body: TournamentShiftInput) => + api.post(`/tournaments/${tournamentId}/shifts/`, body), + update: (tournamentId: number, id: number, body: Partial) => + api.patch(`/tournaments/${tournamentId}/shifts/${id}/`, body), + // Cascades — detaches from any events it was attached to, no confirmation guard. + delete: (tournamentId: number, id: number) => + api.delete(`/tournaments/${tournamentId}/shifts/${id}/`), + // 409 on either bounds violation (outside the event's start/end) or + // overlap with another shift already attached to the same event. + attach: (tournamentId: number, eventId: number, shiftId: number) => + api.post(`/tournaments/${tournamentId}/events/${eventId}/shifts/${shiftId}/`, {}), + detach: (tournamentId: number, eventId: number, shiftId: number) => + api.delete(`/tournaments/${tournamentId}/events/${eventId}/shifts/${shiftId}/`), +} + // ------------------------------------------------------------------------- // Tournament Events — nested under /tournaments/{id}/events/ // ------------------------------------------------------------------------- +// name is set only for custom (event_id-less) events — a catalog-linked +// event's display name comes from the joined `event` field instead. export interface TournamentEvent { id: number tournament_id: number - name: string - division: 'B' | 'C' + name: string | null + division: TournamentDivision | null event_type: 'standard' | 'trial' - category: string | null + event_id: number | null + // Joined canonical event — set only when event_id is set. Carries + // category, since TournamentEvent has no category field of its own. + event: CanonicalEvent | null building: string | null room: string | null floor: string | null - volunteers_needed: number - blocks: number[] + volunteers_needed: number | null + // Nullable — a tournament's event schedule isn't known at planning time. + // Warn in the UI on unset times rather than blocking on them. + start_time: string | null + end_time: string | null + shifts: TournamentShift[] created_at: string updated_at: string } +export interface TournamentEventInput { + name?: string | null + division?: TournamentDivision | null + event_type?: 'standard' | 'trial' + event_id?: number | null + building?: string | null + room?: string | null + floor?: string | null + volunteers_needed?: number | null + start_time?: string | null + end_time?: string | null +} + +export interface EventLoadDefaultsSkipped { + event_id: number + division: string + name: string + reason: string +} + +export interface EventLoadDefaultsResponse { + created: TournamentEvent[] + skipped: EventLoadDefaultsSkipped[] +} + export const tournamentEventsApi = { list: (tournamentId: number) => api.get(`/tournaments/${tournamentId}/events/`), get: (tournamentId: number, id: number) => api.get(`/tournaments/${tournamentId}/events/${id}/`), - create: (tournamentId: number, body: Partial) => + create: (tournamentId: number, body: TournamentEventInput & { tournament_id: number }) => api.post(`/tournaments/${tournamentId}/events/`, body), - update: (tournamentId: number, id: number, body: Partial) => + update: (tournamentId: number, id: number, body: Partial) => api.patch(`/tournaments/${tournamentId}/events/${id}/`, body), delete: (tournamentId: number, id: number) => api.delete(`/tournaments/${tournamentId}/events/${id}/`), + // Bulk-creates events from every active SeasonEvent whose division the + // tournament supports. Skips anything already loaded rather than erroring. + loadDefaults: (tournamentId: number) => + api.post(`/tournaments/${tournamentId}/events/load-defaults/`, {}), +} + +// ------------------------------------------------------------------------- +// Season Events — admin-curated per-year/division active event list, drives +// tournamentEventsApi.loadDefaults(). GET is public; writes are admin-only. +// ------------------------------------------------------------------------- +export interface SeasonEvent { + id: number + year: number + division: TournamentDivision + is_active: boolean + event: CanonicalEvent + created_at: string +} + +export interface SeasonEventInput { + event_id: number + year: number + division: TournamentDivision + is_active?: boolean +} + +export const seasonEventsApi = { + list: (params: { year?: number; division?: TournamentDivision | TournamentDivision[] } = {}) => { + const qs = new URLSearchParams(); + if (params.year !== undefined) qs.set('year', String(params.year)) + if (params.division) { + for (const d of Array.isArray(params.division) ? params.division : [params.division]) qs.append('division', d) + } + const query = qs.toString() + return api.get(`/season-events/${query ? `?${query}` : ''}`) + }, + create: (body: SeasonEventInput) => + api.post('/admin/season-events/', body), + update: (id: number, body: Partial) => + api.patch(`/admin/season-events/${id}/`, body), + delete: (id: number) => + api.delete(`/admin/season-events/${id}/`), } // ------------------------------------------------------------------------- @@ -483,11 +620,6 @@ export interface AvailabilitySlot { end: string } -export interface ScheduleSlot { - block: number - duty: string -} - // Matches RoleRead export interface Role { id: number @@ -534,8 +666,6 @@ export interface MembershipSlim { export interface MembershipFull { id: number tournament_id: number - assigned_event_id: number | null - schedule: ScheduleSlot[] | null status: MembershipStatus role_preference: string[] | null event_preference: string[] | null @@ -563,8 +693,7 @@ export interface MembershipMeUpdate { // PATCH .../memberships/{id}/ — manage_members override, day-of logistics only export interface MembershipCoordinatorUpdate { - schedule?: ScheduleSlot[] | null - notes?: string | null + notes?: string | null } // GET .../memberships/me/ — current user's membership + effective permissions @@ -825,19 +954,9 @@ export interface JoinRedeemResponse { // GET /join/preview/ — discriminated on `type`, mirrors the backend's // JoinPreviewTournament/JoinPreviewChapter split. -export interface JoinPreviewTournament { - type: 'tournament' - target_id: number - name: string - short_name: string | null - start_date: string - end_date: string - university: University | null - location: string | null - state: string - level: string - division: string[] - is_verified: boolean +export interface JoinPreviewTournament extends TournamentPublic { + type: 'tournament' + target_id: number } export interface JoinPreviewChapter { diff --git a/frontend/lib/eventDisplay.ts b/frontend/lib/eventDisplay.ts new file mode 100644 index 00000000..7de879fb --- /dev/null +++ b/frontend/lib/eventDisplay.ts @@ -0,0 +1,13 @@ +import { TournamentEvent } from "@/lib/api"; + +export function eventName(e: TournamentEvent): string { + return e.event?.name ?? e.name ?? "—"; +} + +// Name alone can collide across divisions (e.g. two "Chess" events, one per +// division) — pair it with the division so a results report unambiguously +// identifies which row it's talking about. No separator: reads as one label +// ("Chess A"), not name-then-division. +export function eventNameWithDivision(e: TournamentEvent): string { + return e.division ? `${eventName(e)} ${e.division}` : eventName(e); +} diff --git a/frontend/lib/timeFormat.ts b/frontend/lib/timeFormat.ts index 6fb61856..5d75cdcb 100644 --- a/frontend/lib/timeFormat.ts +++ b/frontend/lib/timeFormat.ts @@ -2,6 +2,59 @@ export function formatDate(iso: string): string { return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) } +// For binding an ISO datetime to , whose value +// format is always "YYYY-MM-DDTHH:mm" in the user's local time, no seconds +// or timezone offset. +export function toDatetimeLocal(iso: string | null): string { + if (!iso) return "" + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +export function fromDatetimeLocal(local: string): string | null { + return local ? new Date(local).toISOString() : null +} + +// Day-only ("YYYY-MM-DD") and time-only ("HH:mm") halves of a datetime-local +// value — for a day-picker + time-input pair instead of one combined field, +// e.g. an event/shift editor where the day rarely varies (usually locked to +// the tournament's single day) but the time always does. +export function toDateInput(iso: string | null): string { + if (!iso) return "" + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +export function toTimeInput(iso: string | null): string { + if (!iso) return "" + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, "0") + return `${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +export function fromDayAndTime(day: string, time: string): string | null { + return day && time ? new Date(`${day}T${time}`).toISOString() : null +} + +// Formats a bare "HH:mm" (a time-input value with no date attached) as +// "2:30 PM" — for a view-mode row whose day is already shown in its own +// column, so there's no ISO datetime to format from directly. +export function formatTimeOfDay(hhmm: string): string { + if (!hhmm) return "" + const [h, m] = hhmm.split(":").map(Number) + const period = h < 12 ? "AM" : "PM" + const h12 = h % 12 === 0 ? 12 : h % 12 + return `${h12}:${String(m).padStart(2, "0")} ${period}` +} + +// "Wed, Mar 14" — weekday alongside the date so same-named shifts/events on +// different days of a multi-day tournament aren't ambiguous in a picker. +export function formatDayLabel(dayISO: string): string { + return new Date(`${dayISO}T00:00:00`).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }) +} + // Date + time, for a tooltip pinning down the exact moment behind a coarse // label like formatDuration's "3d" or "Today". export function formatDateTime(iso: string): string { @@ -10,6 +63,13 @@ export function formatDateTime(iso: string): string { }) } +// Time only, no date — for compact display where the date is already +// established by context (e.g. a shift chip inside an event whose own +// start/end date is already shown above it). +export function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) +} + export function parseUserAgent(ua: string | null): string { if (!ua) return "Unknown device" diff --git a/frontend/lib/useLayoutPanel.tsx b/frontend/lib/useLayoutPanel.tsx new file mode 100644 index 00000000..e5f3175d --- /dev/null +++ b/frontend/lib/useLayoutPanel.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { createContext, ReactNode, useCallback, useContext, useMemo, useState } from "react"; + +export interface LayoutPanel { + content: ReactNode; + /** Horizontal space the panel claims in the layout row, in px. */ + width: number; +} + +interface LayoutPanelSetters { + setPanel: (content: ReactNode, width: number) => void; + clearPanel: () => void; +} + +// Split into two contexts on purpose: the *setters* value is stable, so a +// descendant that only registers content (EventsTab) doesn't re-render every +// time the content changes. Sharing one context would mean setPanel -> +// re-render -> new callbacks -> setPanel, i.e. an update loop. +const LayoutPanelContentContext = createContext(null); + +// Defaults are no-ops so components using the hook still work on routes that +// don't mount the provider (same defensive pattern as useUnsavedChanges). +const LayoutPanelSetContext = createContext({ + setPanel: () => {}, + clearPanel: () => {}, +}); + +/** Read the currently registered panel — for the layout shell that renders it. */ +export function useLayoutPanelContent() { + return useContext(LayoutPanelContentContext); +} + +/** Register/clear what the layout-level docked panel slot should show. */ +export function useSetLayoutPanel() { + return useContext(LayoutPanelSetContext); +} + +export function LayoutPanelProvider({ children }: { children: ReactNode }) { + const [panel, setPanelState] = useState(null); + + const setPanel = useCallback((content: ReactNode, width: number) => { + setPanelState({ content, width }); + }, []); + const clearPanel = useCallback(() => setPanelState(null), []); + + const setters = useMemo(() => ({ setPanel, clearPanel }), [setPanel, clearPanel]); + + return ( + + + {children} + + + ); +} diff --git a/frontend/lib/usePanelSelection.tsx b/frontend/lib/usePanelSelection.tsx new file mode 100644 index 00000000..12de23bd --- /dev/null +++ b/frontend/lib/usePanelSelection.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +/** + * The two mutually-exclusive ways a table row can open a docked panel: + * + * - **focus**: "Edit"/"Expand" on a row opens that one row's panel. No + * checkboxes; clicking any *other* row switches which one the panel shows. + * - **select**: explicit Select mode — checkboxes accumulate a selection and + * the panel only opens once "Edit" is pressed in the SelectionBar + * (`massPanelOpen`), not as soon as a row is checked. + * + * Both are frozen while `panelDirty` (reported up by whichever panel is open + * via its `onDirtyChange`): switching rows or changing the selection would + * silently throw away in-progress edits, so it's blocked outright — the + * triggering control disables itself with a "save or discard first" title — + * rather than guarded behind a confirm dialog. + */ +export interface PanelSelection { + focusedId: number | null; + selectMode: boolean; + selectedIds: Set; + massPanelOpen: boolean; + panelDirty: boolean; + + /** Pass straight to each panel's `onDirtyChange`. */ + setPanelDirty: (dirty: boolean) => void; + + /** Row "Edit"/"Expand", or clicking another row while one is focused. */ + focusItem: (id: number) => void; + toggleSelectMode: () => void; + toggleSelected: (id: number) => void; + /** Header "select all" checkbox, over the currently visible ids. */ + toggleSelectAll: (ids: number[], checked: boolean) => void; + /** SelectionBar's "Edit" — the only thing that opens the mass panel. */ + openMassPanel: () => void; + + clearFocus: () => void; + clearSelection: () => void; + /** Drop a deleted/removed row out of whichever state still points at it. */ + forgetItem: (id: number) => void; + /** + * Start a caller-owned flow that owns the panel but has no row behind it + * (Events' "new event" draft). Blocked while dirty and clears focus + + * selection first, so it can't silently replace an in-progress edit. + */ + startExternalFlow: (start: () => void) => void; + + getPrevNext: (items: T[], getId: (item: T) => number) => PrevNext; +} + +export interface PrevNext { + /** Index of the focused row in `items`, or -1 (not focused / filtered out). */ + index: number; + hasPrev: boolean; + hasNext: boolean; + prevId: number | null; + nextId: number | null; +} + +export interface PanelSelectionOptions { + /** + * Called whenever this hook takes over the panel (focusing a row, entering + * select mode) so a caller-owned flow with no row behind it — Events' + * "creating a new event" draft — gets dropped at the same time. Members has + * no such flow, so it omits this. + */ + onClearExternal?: () => void; +} + +export function usePanelSelection(options: PanelSelectionOptions = {}): PanelSelection { + const [focusedId, setFocusedId] = useState(null); + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [massPanelOpen, setMassPanelOpen] = useState(false); + const [panelDirty, setPanelDirty] = useState(false); + + // Read through a ref so the actions below keep stable identities even when + // the caller hands in a fresh closure each render — several of them end up + // in a setPanel effect's dependency list, where a new identity per render + // would re-register the panel in a loop. + const clearExternalRef = useRef(options.onClearExternal); + useEffect(() => { clearExternalRef.current = options.onClearExternal; }); + + const clearFocus = useCallback(() => { + setFocusedId(null); + setPanelDirty(false); + }, []); + + const clearSelection = useCallback(() => { + setSelectedIds(new Set()); + setSelectMode(false); + setMassPanelOpen(false); + setPanelDirty(false); + }, []); + + const toggleSelectMode = useCallback(() => { + if (panelDirty) return; + if (selectMode) { + clearSelection(); + return; + } + // Carries whatever was open into the new mode instead of discarding it: + // a focused row becomes the pre-checked row, and its panel stays open + // (massPanelOpen true) rather than dropping back to the SelectionBar — + // there's already a panel showing it, no reason to close it just to make + // you press Edit again. A caller-owned draft (only reachable here while + // clean, since a dirty one is blocked above) is simply dropped. + clearExternalRef.current?.(); + if (focusedId !== null) { + setSelectedIds(new Set([focusedId])); + setFocusedId(null); + setMassPanelOpen(true); + } + setSelectMode(true); + }, [panelDirty, selectMode, focusedId, clearSelection]); + + // Unchecking back down to zero while the mass panel is open closes it — + // there's nothing left to edit — but leaves Select mode itself on, so the + // SelectionBar reappears instead of exiting selection entirely. Applied at + // every point the selection can shrink rather than in an effect watching + // it, which would cost an extra render pass. + const applySelection = useCallback((next: Set) => { + setSelectedIds(next); + if (next.size === 0) setMassPanelOpen(false); + }, []); + + const toggleSelected = useCallback((id: number) => { + if (panelDirty) return; + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + applySelection(next); + }, [panelDirty, selectedIds, applySelection]); + + const toggleSelectAll = useCallback((ids: number[], checked: boolean) => { + const next = new Set(selectedIds); + ids.forEach((id) => (checked ? next.add(id) : next.delete(id))); + applySelection(next); + }, [selectedIds, applySelection]); + + // Always lands in plain single-focus mode — it never turns Select mode on, + // and drops out of it (or out of a caller-owned draft) if either was + // already active, replacing whichever panel was open. + const focusItem = useCallback((id: number) => { + if (panelDirty) return; + clearExternalRef.current?.(); + setSelectMode(false); + setSelectedIds(new Set()); + setMassPanelOpen(false); + setFocusedId(id); + }, [panelDirty]); + + const openMassPanel = useCallback(() => setMassPanelOpen(true), []); + + const startExternalFlow = useCallback((start: () => void) => { + if (panelDirty) return; + clearFocus(); + clearSelection(); + start(); + }, [panelDirty, clearFocus, clearSelection]); + + // Otherwise a deleted-but-still-selected/focused row would keep a panel + // open against a row that no longer exists. + const forgetItem = useCallback((id: number) => { + if (selectedIds.has(id)) { + const next = new Set(selectedIds); + next.delete(id); + applySelection(next); + } + setFocusedId((prev) => (prev === id ? null : prev)); + }, [selectedIds, applySelection]); + + // Prev/next only make sense for the plain single-focus flow (not while + // mass-editing several at once) and step through the caller's own current + // filter/sort order, so switching sort or narrowing a filter mid-edit still + // lands somewhere sensible. Returns plain ids/booleans rather than + // callbacks so callers can put them in a dependency array without + // re-registering their panel every render. + const getPrevNext = useCallback((items: T[], getId: (item: T) => number): PrevNext => { + const index = focusedId !== null ? items.findIndex((item) => getId(item) === focusedId) : -1; + const hasPrev = !panelDirty && index > 0; + const hasNext = !panelDirty && index !== -1 && index < items.length - 1; + return { + index, + hasPrev, + hasNext, + prevId: hasPrev ? getId(items[index - 1]) : null, + nextId: hasNext ? getId(items[index + 1]) : null, + }; + }, [focusedId, panelDirty]); + + return { + focusedId, selectMode, selectedIds, massPanelOpen, panelDirty, + setPanelDirty, + focusItem, toggleSelectMode, toggleSelected, toggleSelectAll, openMassPanel, + clearFocus, clearSelection, forgetItem, startExternalFlow, + getPrevNext, + }; +} diff --git a/frontend/lib/usePersistedFilter.tsx b/frontend/lib/usePersistedFilter.tsx new file mode 100644 index 00000000..b758bb6e --- /dev/null +++ b/frontend/lib/usePersistedFilter.tsx @@ -0,0 +1,77 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { FilterState, emptyFilterState } from "@/components/ui/FilterModal"; + +// Scoped per user *and* tournament: logout() doesn't clear localStorage, so an +// unscoped key would leak one account's filters into the next one on a shared +// device (and one tournament's into another). +function storageKeyFor(table: string, userId: number | undefined, tournamentId: number | undefined): string | null { + if (userId == null || tournamentId == null || !Number.isFinite(tournamentId)) return null; + return `filters:${userId}:${tournamentId}:${table}`; +} + +/** + * Committed filter state for one table, mirrored into localStorage. + * + * Sets don't survive JSON.stringify (they serialize to `{}`), so the stored + * shape is `{ field: string[] }` and this hook converts both ways. + * + * Returns the applied filters plus a setter that persists — call it on Apply + * / Clear only, never from a draft edit. + */ +export function usePersistedFilter( + table: string, + userId: number | undefined, + tournamentId: number | undefined, + keys: readonly K[], +): [FilterState, (next: FilterState) => void] { + // The caller passes an array literal or const tuple; pinning it in a ref + // keeps it out of effect/callback deps without risking a stale-identity loop. + const keysRef = useRef(keys); + keysRef.current = keys; + + const [filters, setFilters] = useState>(() => emptyFilterState(keys)); + + const storageKey = storageKeyFor(table, userId, tournamentId); + + // Hydrate once per key. `null` (user/tournament still loading) is simply + // skipped — the effect re-runs when the key materializes. + const hydratedKey = useRef(null); + useEffect(() => { + if (!storageKey || hydratedKey.current === storageKey) return; + hydratedKey.current = storageKey; + const next = emptyFilterState(keysRef.current); + try { + const raw = window.localStorage.getItem(storageKey); + if (raw) { + const parsed = JSON.parse(raw) as Record; + for (const key of keysRef.current) { + const values = parsed[key]; + if (Array.isArray(values)) next[key] = new Set(values.filter((v): v is string => typeof v === "string")); + } + } + } catch { + // Corrupt entry or storage unavailable (private mode) — fall back to + // "no filters" rather than breaking the page. + } + setFilters(next); + }, [storageKey]); + + const apply = useCallback((next: FilterState) => { + setFilters(next); + if (!storageKey) return; + try { + const plain = Object.fromEntries(keysRef.current.map((k) => [k, [...next[k]]])); + const anyActive = Object.values(plain).some((values) => values.length > 0); + // Clearing removes the entry outright so a stale "everything shown" + // blob doesn't linger in storage forever. + if (anyActive) window.localStorage.setItem(storageKey, JSON.stringify(plain)); + else window.localStorage.removeItem(storageKey); + } catch { + // Quota/private-mode failures shouldn't block the in-memory filter. + } + }, [storageKey]); + + return [filters, apply]; +} diff --git a/frontend/lib/useTournament.tsx b/frontend/lib/useTournament.tsx index a872ad4d..3371ee2d 100644 --- a/frontend/lib/useTournament.tsx +++ b/frontend/lib/useTournament.tsx @@ -6,15 +6,34 @@ import { useState, useEffect, useCallback, + useMemo, ReactNode, } from "react"; -import { tournamentsApi, Tournament } from "./api"; +import { tournamentsApi, Tournament, TournamentSummary } from "./api"; + +// Every calendar day between start_date/end_date, inclusive — internal to +// deriving `days` below; nothing else needs a tournament's day list. +function tournamentDays(startDate: string, endDate: string): string[] { + const days: string[] = []; + const pad = (n: number) => String(n).padStart(2, "0"); + let cur = new Date(`${startDate}T00:00:00`); + const end = new Date(`${endDate}T00:00:00`); + while (cur <= end) { + days.push(`${cur.getFullYear()}-${pad(cur.getMonth() + 1)}-${pad(cur.getDate())}`); + cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1); + } + return days; +} interface TournamentContextValue { - tournaments: Tournament[]; + tournaments: TournamentSummary[]; selectedTournament: Tournament | null; setSelectedTournament: (t: Tournament | null) => void; isArchived: boolean; + /** Every calendar day the tournament spans, inclusive — [] before a tournament is selected. */ + days: string[]; + /** days.length > 1 — whether a day picker is actually meaningful, or should just lock to the one day. */ + isMultiDay: boolean; loading: boolean; refresh: () => Promise; } @@ -22,11 +41,18 @@ interface TournamentContextValue { const TournamentContext = createContext(null); export function TournamentProvider({ children }: { children: ReactNode }) { - const [tournaments, setTournaments] = useState([]); - const [selectedTournament, setSelectedTournamentState] = + const [tournaments, setTournaments] = useState([]); + const [selectedTournament, setSelectedTournament] = useState(null); const [loading, setLoading] = useState(true); + // Only populates the `tournaments` list (used by the Topbar switcher). + // selectedTournament is never set from here — every route lives under + // /dashboard/tournaments/[id], so TournamentShell's own fetch keyed to + // that URL id is the sole writer. Letting this list refresh also assign + // selectedTournament (previously via localStorage) raced with that fetch: + // this list call is slower, so it would reliably land second and clobber + // the correct URL-driven selection with a stale cached one. const refresh = useCallback(async () => { setLoading(true); try { @@ -34,20 +60,6 @@ export function TournamentProvider({ children }: { children: ReactNode }) { // has any membership in (admin sees all tournaments) const data = await tournamentsApi.list(); setTournaments(data); - - // Restore last selected from localStorage, or default to first - const savedId = localStorage.getItem("nexus_selected_tournament"); - if (savedId) { - const found = data.find((t: Tournament) => t.id === parseInt(savedId)); - if (found) { - setSelectedTournamentState(found); - setLoading(false); - return; - } - } - if (data.length > 0) { - setSelectedTournamentState(data[0]); - } } catch (err) { console.error("Failed to load tournaments", err); } finally { @@ -55,19 +67,15 @@ export function TournamentProvider({ children }: { children: ReactNode }) { } }, []); - const setSelectedTournament = useCallback((t: Tournament | null) => { - setSelectedTournamentState(t); - if (t) { - localStorage.setItem("nexus_selected_tournament", String(t.id)); - } else { - localStorage.removeItem("nexus_selected_tournament"); - } - }, []); - useEffect(() => { refresh(); }, [refresh]); + const days = useMemo( + () => (selectedTournament ? tournamentDays(selectedTournament.start_date, selectedTournament.end_date) : []), + [selectedTournament] + ); + return ( 1, loading, refresh, }}