From d6739700f34fab12767d6ea0b7b7b2ee155bd499 Mon Sep 17 00:00:00 2001 From: Nathan Chen Date: Tue, 7 Apr 2026 01:32:48 -0400 Subject: [PATCH 001/100] asdfasdfasdf --- backend/app/api/routes/categories.py | 89 ++++++++++++++++ backend/app/api/routes/events.py | 86 +++++++++------- backend/app/api/routes/time_blocks.py | 98 ++++++++++++++++++ backend/app/api/routes/tournaments.py | 52 ++++------ backend/app/core/permissions.py | 14 +++ backend/app/main.py | 4 +- backend/app/models/models.py | 112 ++++++++++++++++++--- backend/app/schemas/event.py | 29 ++---- backend/app/schemas/time_block.py | 80 +++++++++++++++ backend/app/schemas/tournament.py | 4 +- backend/app/schemas/tournament_category.py | 20 ++++ 11 files changed, 480 insertions(+), 108 deletions(-) create mode 100644 backend/app/api/routes/categories.py create mode 100644 backend/app/api/routes/time_blocks.py create mode 100644 backend/app/schemas/time_block.py create mode 100644 backend/app/schemas/tournament_category.py diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py new file mode 100644 index 00000000..37dece9a --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -0,0 +1,89 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.models.models import TournamentCategory, Event, User +from app.schemas.tournament_category import TournamentCategoryCreate, TournamentCategoryRead +from app.core.permissions import MANAGE_EVENTS, VIEW_EVENTS, require_permission + +router = APIRouter(prefix="/tournaments/{tournament_id}/categories", tags=["categories"]) + + +@router.get("/", response_model=list[TournamentCategoryRead]) +def list_categories( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(VIEW_EVENTS)), +): + """List all categories for a tournament (seeded + custom).""" + return ( + db.query(TournamentCategory) + .filter(TournamentCategory.tournament_id == tournament_id) + .all() + ) + + +@router.post("/", response_model=TournamentCategoryRead, status_code=status.HTTP_201_CREATED) +def create_category( + tournament_id: int, + payload: TournamentCategoryCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Create a new custom category.""" + # Check if category with same name already exists + existing = ( + db.query(TournamentCategory) + .filter( + TournamentCategory.tournament_id == tournament_id, + TournamentCategory.name == payload.name, + ) + .first() + ) + if existing: + raise HTTPException(status_code=400, detail="Category already exists") + + db_cat = TournamentCategory( + **payload.model_dump(), + tournament_id=tournament_id, + is_custom=True, + ) + db.add(db_cat) + db.commit() + db.refresh(db_cat) + return db_cat + + +@router.delete("/{cat_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_category( + tournament_id: int, + cat_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Delete a custom category. Seeded categories cannot be deleted.""" + db_cat = ( + db.query(TournamentCategory) + .filter( + TournamentCategory.id == cat_id, + TournamentCategory.tournament_id == tournament_id, + ) + .first() + ) + if not db_cat: + raise HTTPException(status_code=404, detail="Category not found") + + if not db_cat.is_custom: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Seeded categories cannot be deleted", + ) + + # Check for events using this category + if db_cat.events: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="This category is in use and cannot be deleted.", + ) + + db.delete(db_cat) + db.commit() diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index d538df41..15e9793f 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -1,5 +1,5 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from app.core.auth import get_current_user @@ -7,12 +7,11 @@ MANAGE_EVENTS, MANAGE_TOURNAMENT, VIEW_EVENTS, - require_membership, require_permission, has_permission, ) from app.db.session import get_db -from app.models.models import Event, Tournament, User +from app.models.models import Event, Tournament, User, TimeBlock, TournamentCategory from app.schemas.event import EventCreate, EventRead, EventUpdate # Routes are nested: /tournaments/{tournament_id}/events/... @@ -20,24 +19,6 @@ router = APIRouter(prefix="/tournaments/{tournament_id}/events", tags=["events"]) -def _serialize(event: Event) -> 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 _get_event_or_404(event_id: int, tournament_id: int, db: Session) -> Event: """ Fetch event by ID and validate it belongs to the given tournament. @@ -69,21 +50,24 @@ def _require_write_permission(user: User, tournament_id: int, db: Session) -> No @router.get("/", response_model=list[EventRead]) def list_events( tournament_id: int, + category_id: int | None = Query(None), + division: str | None = Query(None), + type: str | None = Query(None, alias="type"), # mapping "type" param to event_type db: Session = Depends(get_db), current_user: User = Depends(require_permission(VIEW_EVENTS)), ): - """List all events for a tournament, ordered by division then name.""" - tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first() - if not tournament: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament not found") + """List all events for a tournament, with optional filters.""" + query = db.query(Event).filter(Event.tournament_id == tournament_id) + + if category_id is not None: + query = query.filter(Event.category_id == category_id) + if division is not None: + query = query.filter(Event.division == division) + if type is not None: + query = query.filter(Event.event_type == type) - events = ( - db.query(Event) - .filter(Event.tournament_id == tournament_id) - .order_by(Event.division, Event.name) - .all() - ) - return [_serialize(e) for e in events] + events = query.order_by(Event.division, Event.name).all() + return events # --------------------------------------------------------------------------- @@ -96,7 +80,7 @@ def get_event( db: Session = Depends(get_db), current_user: User = Depends(require_permission(VIEW_EVENTS)), ): - return _serialize(_get_event_or_404(event_id, tournament_id, db)) + return _get_event_or_404(event_id, tournament_id, db) # --------------------------------------------------------------------------- @@ -133,11 +117,25 @@ def create_event( detail=f"Event '{payload.name}' division {payload.division} already exists in this tournament", ) - event = Event(**payload.model_dump()) + # Extract time_block_ids from payload + data = payload.model_dump(exclude={"time_block_ids"}) + event = Event(**data) + + if payload.time_block_ids: + blocks = ( + db.query(TimeBlock) + .filter( + TimeBlock.id.in_(payload.time_block_ids), + TimeBlock.tournament_id == tournament_id, + ) + .all() + ) + event.time_blocks = blocks + db.add(event) db.commit() db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -154,12 +152,24 @@ def update_event( _require_write_permission(current_user, tournament_id, db) event = _get_event_or_404(event_id, tournament_id, db) - for field, value in payload.model_dump(exclude_none=True).items(): + update_data = payload.model_dump(exclude_none=True, exclude={"time_block_ids"}) + for field, value in update_data.items(): setattr(event, field, value) + if payload.time_block_ids is not None: + blocks = ( + db.query(TimeBlock) + .filter( + TimeBlock.id.in_(payload.time_block_ids), + TimeBlock.tournament_id == tournament_id, + ) + .all() + ) + event.time_blocks = blocks + db.commit() db.refresh(event) - return _serialize(event) + return event # --------------------------------------------------------------------------- @@ -175,4 +185,4 @@ def delete_event( _require_write_permission(current_user, tournament_id, db) event = _get_event_or_404(event_id, tournament_id, db) db.delete(event) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/app/api/routes/time_blocks.py b/backend/app/api/routes/time_blocks.py new file mode 100644 index 00000000..33c1594e --- /dev/null +++ b/backend/app/api/routes/time_blocks.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from app.db.session import get_db +from app.models.models import TimeBlock, Event, User +from app.schemas.time_block import TimeBlockCreate, TimeBlockUpdate, TimeBlockRead +from app.core.permissions import MANAGE_EVENTS, VIEW_EVENTS, require_permission + +router = APIRouter(prefix="/tournaments/{tournament_id}/blocks", tags=["time-blocks"]) + + +@router.get("/", response_model=list[TimeBlockRead]) +def list_time_blocks( + tournament_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(VIEW_EVENTS)), +): + """List all time blocks for a tournament, ordered by date and start time.""" + return ( + db.query(TimeBlock) + .filter(TimeBlock.tournament_id == tournament_id) + .order_by(TimeBlock.date, TimeBlock.start) + .all() + ) + + +@router.post("/", response_model=TimeBlockRead, status_code=status.HTTP_201_CREATED) +def create_time_block( + tournament_id: int, + payload: TimeBlockCreate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Create a new time block.""" + db_block = TimeBlock(**payload.model_dump(), tournament_id=tournament_id) + db.add(db_block) + db.commit() + db.refresh(db_block) + return db_block + + +@router.patch("/{block_id}/", response_model=TimeBlockRead) +def update_time_block( + tournament_id: int, + block_id: int, + payload: TimeBlockUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Update a time block.""" + db_block = ( + db.query(TimeBlock) + .filter(TimeBlock.id == block_id, TimeBlock.tournament_id == tournament_id) + .first() + ) + if not db_block: + raise HTTPException(status_code=404, detail="Time block not found") + + update_data = payload.model_dump(exclude_none=True) + for field, value in update_data.items(): + setattr(db_block, field, value) + + db.commit() + db.refresh(db_block) + return db_block + + +@router.delete("/{block_id}/", status_code=status.HTTP_204_NO_CONTENT) +def delete_time_block( + tournament_id: int, + block_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Delete a time block. Returns 409 if events are assigned.""" + db_block = ( + db.query(TimeBlock) + .filter(TimeBlock.id == block_id, TimeBlock.tournament_id == tournament_id) + .first() + ) + if not db_block: + raise HTTPException(status_code=404, detail="Time block not found") + + # Check for assigned events + if db_block.events: + affected_events = [ + {"id": e.id, "name": e.name, "division": e.division} + for e in db_block.events + ] + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "This block has assigned events and cannot be deleted.", + "affected_events": affected_events, + }, + ) + + db.delete(db_block) + db.commit() diff --git a/backend/app/api/routes/tournaments.py b/backend/app/api/routes/tournaments.py index 0a79ce4f..2ae6533f 100644 --- a/backend/app/api/routes/tournaments.py +++ b/backend/app/api/routes/tournaments.py @@ -4,40 +4,19 @@ from app.core.auth import get_current_user, require_admin from app.core.permissions import ( DEFAULT_POSITIONS, + DEFAULT_CATEGORIES, MANAGE_TOURNAMENT, require_membership, require_permission, has_any_membership, ) from app.db.session import get_db -from app.models.models import Membership, Tournament, User +from app.models.models import Membership, Tournament, User, TournamentCategory from app.schemas.tournament import TournamentCreate, TournamentRead, TournamentUpdate router = APIRouter(prefix="/tournaments", tags=["tournaments"]) -def _serialize(tournament: Tournament) -> dict: - """ - Convert JSON columns (blocks, volunteer_schema) from their stored dict/list - form into the nested Pydantic-compatible structure for TournamentRead. - """ - return { - "id": tournament.id, - "name": tournament.name, - "start_date": tournament.start_date, - "end_date": tournament.end_date, - "location": tournament.location, - "blocks": tournament.blocks or [], - "volunteer_schema": tournament.volunteer_schema or { - "custom_fields": [], - "positions": [], - }, - "owner_id": tournament.owner_id, - "created_at": tournament.created_at, - "updated_at": tournament.updated_at, - } - - # --------------------------------------------------------------------------- # GET /tournaments/ — admin only (global list) # --------------------------------------------------------------------------- @@ -51,7 +30,7 @@ def list_all_tournaments( Regular users should use GET /tournaments/me instead. """ tournaments = db.query(Tournament).order_by(Tournament.created_at.desc()).all() - return [_serialize(t) for t in tournaments] + return tournaments # --------------------------------------------------------------------------- @@ -76,7 +55,7 @@ def list_my_tournaments( .order_by(Tournament.created_at.desc()) .all() ) - return [_serialize(t) for t in tournaments] + return tournaments # --------------------------------------------------------------------------- @@ -90,11 +69,8 @@ def create_tournament( current_user: User = Depends(get_current_user), ): data = payload.model_dump() - data["blocks"] = [b.model_dump() for b in payload.blocks] # Build volunteer_schema — merge the submitted schema with DEFAULT_POSITIONS. - # If the TD supplied positions in the payload we respect them; otherwise - # we auto-populate the defaults. submitted_schema = payload.volunteer_schema.model_dump() if not submitted_schema.get("positions"): submitted_schema["positions"] = DEFAULT_POSITIONS @@ -102,7 +78,15 @@ def create_tournament( tournament = Tournament(**data, owner_id=current_user.id) db.add(tournament) - db.flush() # get tournament.id before creating membership + db.flush() # get tournament.id before creating membership and categories + + # Seed DEFAULT_CATEGORIES + for cat_name in DEFAULT_CATEGORIES: + db.add(TournamentCategory( + tournament_id=tournament.id, + name=cat_name, + is_custom=False + )) # Auto-create a tournament_director membership for the creator. membership = Membership( @@ -114,7 +98,7 @@ def create_tournament( db.add(membership) db.commit() db.refresh(tournament) - return _serialize(tournament) + return tournament # --------------------------------------------------------------------------- @@ -133,7 +117,7 @@ def get_tournament( tournament = db.query(Tournament).filter(Tournament.id == tournament_id).first() if not tournament: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament not found") - return _serialize(tournament) + return tournament # --------------------------------------------------------------------------- @@ -152,8 +136,6 @@ def update_tournament( update_data = payload.model_dump(exclude_none=True) - if "blocks" in update_data: - update_data["blocks"] = [b.model_dump() for b in payload.blocks] if "volunteer_schema" in update_data: update_data["volunteer_schema"] = payload.volunteer_schema.model_dump() @@ -162,7 +144,7 @@ def update_tournament( db.commit() db.refresh(tournament) - return _serialize(tournament) + return tournament # --------------------------------------------------------------------------- @@ -191,4 +173,4 @@ def delete_tournament( ) db.delete(tournament) - db.commit() \ No newline at end of file + db.commit() diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 36b03e0d..65fb32d9 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -144,6 +144,20 @@ ] +# --------------------------------------------------------------------------- +# Default category definitions +# Auto-populated into tournament_categories when a tournament is created. +# --------------------------------------------------------------------------- + +DEFAULT_CATEGORIES: list[str] = [ + "Life, Personal & Social Science", + "Earth and Space Science", + "Physical Science & Chemistry", + "Technology & Engineering", + "Inquiry & Nature of Science", +] + + # --------------------------------------------------------------------------- # Runtime permission helpers # --------------------------------------------------------------------------- diff --git a/backend/app/main.py b/backend/app/main.py index c82b08f2..ed07a858 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,7 +7,7 @@ from app.core.security import verify_api_key from app.db.init_db import init_db, seed_dev_data from app.api.routes import tournaments, sheets, events, users, memberships -from app.api.routes import auth +from app.api.routes import auth, time_blocks, categories settings = get_settings() @@ -52,6 +52,8 @@ async def lifespan(app: FastAPI): app.include_router(auth.router, prefix="", dependencies=[api_key_dependency]) app.include_router(tournaments.router, prefix="", dependencies=[api_key_dependency]) app.include_router(events.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(time_blocks.router, prefix="", dependencies=[api_key_dependency]) +app.include_router(categories.router, prefix="", dependencies=[api_key_dependency]) app.include_router(memberships.router, prefix="", dependencies=[api_key_dependency]) app.include_router(sheets.router, prefix="", dependencies=[api_key_dependency]) app.include_router(users.router, prefix="", dependencies=[api_key_dependency]) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 0ab2b383..477b8b44 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -35,9 +35,6 @@ class Tournament(Base): end_date = Column(DateTime, nullable=True) location = Column(String(255), nullable=True) - # [{number, label, date, start, end}, ...] - blocks = Column(JSON, nullable=False, default=list) - # { # custom_fields: [{key, label, type}, ...], # positions: [{key, label, permissions: [...]}, ...] @@ -63,6 +60,73 @@ class Tournament(Base): memberships = relationship( "Membership", back_populates="tournament", cascade="all, delete-orphan" ) + time_blocks = relationship( + "TimeBlock", back_populates="tournament", cascade="all, delete-orphan" + ) + categories = relationship( + "TournamentCategory", back_populates="tournament", cascade="all, delete-orphan" + ) + + +# --------------------------------------------------------------------------- +# [ACTIVE] TimeBlock +# --------------------------------------------------------------------------- +class TimeBlock(Base): + __tablename__ = "time_blocks" + + 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) + date = Column(String(10), nullable=False) # "YYYY-MM-DD" + start = Column(String(5), nullable=False) # "HH:MM" 24hr + end = Column(String(5), nullable=False) # "HH:MM" 24hr + + created_at = Column(DateTime, default=utcnow) + updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) + + tournament = relationship("Tournament", back_populates="time_blocks") + events = relationship( + "Event", secondary="event_time_blocks", back_populates="time_blocks" + ) + + +# --------------------------------------------------------------------------- +# [ACTIVE] EventTimeBlock (Association Table) +# --------------------------------------------------------------------------- +class EventTimeBlock(Base): + __tablename__ = "event_time_blocks" + + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True + ) + time_block_id = Column( + Integer, ForeignKey("time_blocks.id", ondelete="CASCADE"), primary_key=True + ) + + +# --------------------------------------------------------------------------- +# [ACTIVE] TournamentCategory +# --------------------------------------------------------------------------- +class TournamentCategory(Base): + __tablename__ = "tournament_categories" + + id = Column(Integer, primary_key=True, index=True) + tournament_id = Column( + Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False + ) + name = Column(String(255), nullable=False) + is_custom = Column(Boolean, nullable=False, default=False) + + created_at = Column(DateTime, default=utcnow) + + tournament = relationship("Tournament", back_populates="categories") + events = relationship("Event", back_populates="category") + + __table_args__ = ( + UniqueConstraint("tournament_id", "name", name="uq_tournament_category_name"), + ) # --------------------------------------------------------------------------- @@ -119,7 +183,7 @@ class User(Base): # tournament. Position definitions (including permissions) live in # Tournament.volunteer_schema["positions"] and can be customised per-tournament. # -# schedule: day-of block assignments (e.g. [{"block": 1, "duty": "event_supervisor"}]) +# schedule: day-of block assignments (e.g. [{"time_block_id": 1, "duty": "event_supervisor"}]) # Only populated for volunteers with day-of duties. One entry per block. # Separate from positions — a volunteer_coordinator might be an event_supervisor # during competition blocks. @@ -139,18 +203,14 @@ class Membership(Base): tournament_id = Column( Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) - assigned_event_id = Column( - Integer, ForeignKey("events.id", ondelete="SET NULL"), nullable=True - ) # Title(s) + permission level within this tournament. # List of position keys defined in tournament.volunteer_schema["positions"]. # e.g. ["lead_event_supervisor", "test_writer"] positions = Column(JSON, nullable=True) - # Day-of block schedule — [{block: int, duty: str}, ...] + # Day-of block schedule — [{time_block_id: int, duty: str}, ...] # One entry per block. duty is a free string (typically a position key). - # e.g. [{"block": 1, "duty": "event_supervisor"}, {"block": 7, "duty": "scoring"}] schedule = Column(JSON, nullable=True) # Volunteer availability/assignment status @@ -182,7 +242,9 @@ class Membership(Base): # Relationships user = relationship("User", back_populates="memberships") tournament = relationship("Tournament", back_populates="memberships") - assigned_event = relationship("Event", back_populates="memberships") + events = relationship( + "Event", secondary="membership_events", back_populates="memberships" + ) __table_args__ = ( # One membership per user per tournament @@ -190,6 +252,20 @@ class Membership(Base): ) +# --------------------------------------------------------------------------- +# [ACTIVE] MembershipEvent (Association Table) +# --------------------------------------------------------------------------- +class MembershipEvent(Base): + __tablename__ = "membership_events" + + membership_id = Column( + Integer, ForeignKey("memberships.id", ondelete="CASCADE"), primary_key=True + ) + event_id = Column( + Integer, ForeignKey("events.id", ondelete="CASCADE"), primary_key=True + ) + + # --------------------------------------------------------------------------- # [ACTIVE] SheetConfig # --------------------------------------------------------------------------- @@ -229,19 +305,27 @@ class Event(Base): Integer, ForeignKey("tournaments.id", ondelete="CASCADE"), nullable=False ) name = Column(String(255), nullable=False) - division = Column(String(4), nullable=False) # "B" | "C" + division = Column(String(4), nullable=True) # "B" | "C" | null event_type = Column(String(32), nullable=False, default="standard") # "standard" | "trial" - category = Column(String(255), nullable=True) + category_id = Column( + Integer, ForeignKey("tournament_categories.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] + created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) tournament = relationship("Tournament", back_populates="events") - memberships = relationship("Membership", back_populates="assigned_event") + category = relationship("TournamentCategory", back_populates="events") + time_blocks = relationship( + "TimeBlock", secondary="event_time_blocks", back_populates="events" + ) + memberships = relationship( + "Membership", secondary="membership_events", back_populates="events" + ) __table_args__ = ( UniqueConstraint("tournament_id", "name", "division", name="uq_tournament_event_division"), diff --git a/backend/app/schemas/event.py b/backend/app/schemas/event.py index 064d6c0d..5a2316e8 100644 --- a/backend/app/schemas/event.py +++ b/backend/app/schemas/event.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime from pydantic import BaseModel, field_validator +from app.schemas.time_block import TimeBlockRead VALID_DIVISIONS = {"B", "C"} VALID_EVENT_TYPES = {"standard", "trial"} @@ -8,21 +9,18 @@ class EventBase(BaseModel): name: str - division: str + division: str | None = None event_type: str = "standard" - category: str | None = None + category_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] = [] @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 @@ -40,18 +38,10 @@ def validate_volunteers_needed(cls, v: int) -> int: 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) - class EventCreate(EventBase): tournament_id: int + time_block_ids: list[int] = [] class EventUpdate(BaseModel): @@ -59,18 +49,19 @@ class EventUpdate(BaseModel): name: str | None = None division: str | None = None event_type: str | None = None - category: str | None = None + category_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 + time_block_ids: list[int] | None = None class EventRead(EventBase): id: int tournament_id: int + time_blocks: list[TimeBlockRead] created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} \ No newline at end of file + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/time_block.py b/backend/app/schemas/time_block.py new file mode 100644 index 00000000..44e26d88 --- /dev/null +++ b/backend/app/schemas/time_block.py @@ -0,0 +1,80 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel, field_validator + + +class TimeBlockBase(BaseModel): + label: str + date: str # "YYYY-MM-DD" + start: str # "HH:MM" 24hr + end: str # "HH:MM" 24hr + + @field_validator("date") + @classmethod + def validate_date_format(cls, v: str) -> str: + from datetime import date as date_type + try: + date_type.fromisoformat(v) + except ValueError: + raise ValueError("date must be in YYYY-MM-DD format") + return v + + @field_validator("start", "end") + @classmethod + def validate_time_format(cls, v: str) -> str: + parts = v.split(":") + if len(parts) != 2: + raise ValueError("Time must be in HH:MM format") + h, m = parts + if not h.isdigit() or not m.isdigit(): + raise ValueError("Time must be in HH:MM format") + if not (0 <= int(h) <= 23) or not (0 <= int(m) <= 59): + raise ValueError("Invalid time value") + return v + + +class TimeBlockCreate(TimeBlockBase): + pass + + +class TimeBlockUpdate(BaseModel): + label: str | None = None + date: str | None = None + start: str | None = None + end: str | None = None + + @field_validator("date") + @classmethod + def validate_date_format(cls, v: str | None) -> str | None: + if v is None: + return v + from datetime import date as date_type + try: + date_type.fromisoformat(v) + except ValueError: + raise ValueError("date must be in YYYY-MM-DD format") + return v + + @field_validator("start", "end") + @classmethod + def validate_time_format(cls, v: str | None) -> str | None: + if v is None: + return v + parts = v.split(":") + if len(parts) != 2: + raise ValueError("Time must be in HH:MM format") + h, m = parts + if not h.isdigit() or not m.isdigit(): + raise ValueError("Time must be in HH:MM format") + if not (0 <= int(h) <= 23) or not (0 <= int(m) <= 59): + raise ValueError("Invalid time value") + return v + + +class TimeBlockRead(TimeBlockBase): + id: int + tournament_id: int + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index b9b92a6e..8154732d 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -152,7 +152,9 @@ class TournamentUpdate(BaseModel): class TournamentRead(TournamentBase): id: int owner_id: int + time_blocks: list[TimeBlockRead] + categories: list[TournamentCategoryRead] created_at: datetime updated_at: datetime - model_config = {"from_attributes": True} \ No newline at end of file + model_config = {"from_attributes": True} diff --git a/backend/app/schemas/tournament_category.py b/backend/app/schemas/tournament_category.py new file mode 100644 index 00000000..af603785 --- /dev/null +++ b/backend/app/schemas/tournament_category.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel + + +class TournamentCategoryBase(BaseModel): + name: str + + +class TournamentCategoryCreate(TournamentCategoryBase): + pass + + +class TournamentCategoryRead(TournamentCategoryBase): + id: int + tournament_id: int + is_custom: bool + created_at: datetime + + model_config = {"from_attributes": True} From dbf9ad9a374934d6429d1fa3e31edb96fc36b7d1 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:21:12 -0700 Subject: [PATCH 002/100] feat: add alembic migration for time_blocks, categories, and membership_events - create time_blocks table with tournament FK, label, date/start/end string columns - create tournament_categories table with unique constraint on (tournament_id, name) - create event_time_blocks and membership_events association tables - backfill time_blocks from tournaments.blocks JSON, event_time_blocks from events.blocks integer lists - backfill membership_events from memberships.assigned_event_id - add events.category_id FK, backfill from free-text events.category string (find-or-create categories) - migrate memberships.schedule JSON to use time_block_id instead of raw block integers - drop legacy columns: tournaments.blocks, events.blocks, events.category, memberships.assigned_event_id --- ...ime_blocks_categories_membership_events.py | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py diff --git a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py new file mode 100644 index 00000000..34ee7c0e --- /dev/null +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -0,0 +1,243 @@ +"""Add time_blocks, tournament_categories, event_time_blocks, membership_events + +Revision ID: 133d0c5a5ffe +Revises: 23ff6e84620b +Create Date: 2026-04-11 + +Migration sequence: +1. Create time_blocks, tournament_categories tables +2. Create event_time_blocks, membership_events association tables +3. Backfill time_blocks from tournaments.blocks JSON +4. Backfill event_time_blocks from events.blocks integer list +5. Add events.category_id, backfill from events.category string (find-or-create TournamentCategory) +6. Backfill membership_events from memberships.assigned_event_id +7. Migrate memberships.schedule JSON: replace block integers with time_block_ids +8. Drop legacy columns: tournaments.blocks, events.blocks, events.category, memberships.assigned_event_id +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import text + +# revision identifiers +revision = '133d0c5a5ffe' +down_revision = '23ff6e84620b' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # ------------------------------------------------------------------ + # 1. Create time_blocks + # ------------------------------------------------------------------ + op.create_table( + 'time_blocks', + sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), + sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), + sa.Column('label', sa.String(255), nullable=False), + sa.Column('date', sa.String(10), nullable=False), + sa.Column('start', sa.String(5), nullable=False), + sa.Column('end', sa.String(5), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + ) + op.create_index('ix_time_blocks_id', 'time_blocks', ['id']) + + # ------------------------------------------------------------------ + # 2. Create tournament_categories + # ------------------------------------------------------------------ + op.create_table( + 'tournament_categories', + sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), + sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('is_custom', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.UniqueConstraint('tournament_id', 'name', name='uq_tournament_category_name'), + ) + op.create_index('ix_tournament_categories_id', 'tournament_categories', ['id']) + + # ------------------------------------------------------------------ + # 3. Create event_time_blocks association table + # ------------------------------------------------------------------ + op.create_table( + 'event_time_blocks', + sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), + sa.Column('time_block_id', sa.Integer(), sa.ForeignKey('time_blocks.id', ondelete='CASCADE'), primary_key=True, nullable=False), + ) + + # ------------------------------------------------------------------ + # 4. Create membership_events association table + # ------------------------------------------------------------------ + op.create_table( + 'membership_events', + sa.Column('membership_id', sa.Integer(), sa.ForeignKey('memberships.id', ondelete='CASCADE'), primary_key=True, nullable=False), + sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), + ) + + # ------------------------------------------------------------------ + # 5. Backfill time_blocks from tournaments.blocks JSON + # tournaments.blocks is a list of objects: + # [{"number": 1, "label": "Block 1", "date": "YYYY-MM-DD", "start": "HH:MM", "end": "HH:MM"}, ...] + # We build a mapping: (tournament_id, old_number) -> new time_block id + # ------------------------------------------------------------------ + from datetime import datetime, timezone + + now = datetime.now(timezone.utc).replace(tzinfo=None) + + # old_number_to_id[tournament_id][block_number] = new time_block id + old_number_to_id = {} + + tournaments = conn.execute(text("SELECT id, blocks FROM tournaments WHERE blocks IS NOT NULL")).fetchall() + for t_id, blocks_json in tournaments: + if not blocks_json: + continue + old_number_to_id[t_id] = {} + for block in blocks_json: + number = block.get("number") + label = block.get("label", f"Block {number}") + date = block.get("date", "") + start = block.get("start", "00:00") + end = block.get("end", "00:00") + + result = conn.execute( + text( + "INSERT INTO time_blocks (tournament_id, label, date, start, end, created_at, updated_at) " + "VALUES (:tid, :label, :date, :start, :end, :now, :now) RETURNING id" + ), + {"tid": t_id, "label": label, "date": date, "start": start, "end": end, "now": now}, + ) + new_id = result.fetchone()[0] + if number is not None: + old_number_to_id[t_id][number] = new_id + + # ------------------------------------------------------------------ + # 6. Backfill event_time_blocks from events.blocks (list of ints) + # ------------------------------------------------------------------ + events = conn.execute(text("SELECT id, tournament_id, blocks FROM events WHERE blocks IS NOT NULL")).fetchall() + for e_id, t_id, blocks_json in events: + if not blocks_json: + continue + t_map = old_number_to_id.get(t_id, {}) + for block_num in blocks_json: + tb_id = t_map.get(block_num) + if tb_id is None: + continue + conn.execute( + text("INSERT INTO event_time_blocks (event_id, time_block_id) VALUES (:eid, :tbid) ON CONFLICT DO NOTHING"), + {"eid": e_id, "tbid": tb_id}, + ) + + # ------------------------------------------------------------------ + # 7. Add events.category_id and backfill from events.category string + # ------------------------------------------------------------------ + op.add_column('events', sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) + + # For each distinct (tournament_id, category) string pair, find-or-create a TournamentCategory row + rows = conn.execute(text( + "SELECT DISTINCT tournament_id, category FROM events WHERE category IS NOT NULL AND category != ''" + )).fetchall() + + cat_cache = {} # (tournament_id, name) -> category_id + for t_id, cat_name in rows: + key = (t_id, cat_name) + if key in cat_cache: + continue + existing = conn.execute( + text("SELECT id FROM tournament_categories WHERE tournament_id = :tid AND name = :name"), + {"tid": t_id, "name": cat_name}, + ).fetchone() + if existing: + cat_cache[key] = existing[0] + else: + result = conn.execute( + text( + "INSERT INTO tournament_categories (tournament_id, name, is_custom, created_at) " + "VALUES (:tid, :name, TRUE, :now) RETURNING id" + ), + {"tid": t_id, "name": cat_name, "now": now}, + ) + cat_cache[key] = result.fetchone()[0] + + # Update events.category_id + for (t_id, cat_name), cat_id in cat_cache.items(): + conn.execute( + text("UPDATE events SET category_id = :cid WHERE tournament_id = :tid AND category = :name"), + {"cid": cat_id, "tid": t_id, "name": cat_name}, + ) + + # ------------------------------------------------------------------ + # 8. Backfill membership_events from memberships.assigned_event_id + # ------------------------------------------------------------------ + memberships = conn.execute( + text("SELECT id, assigned_event_id FROM memberships WHERE assigned_event_id IS NOT NULL") + ).fetchall() + for m_id, e_id in memberships: + conn.execute( + text("INSERT INTO membership_events (membership_id, event_id) VALUES (:mid, :eid) ON CONFLICT DO NOTHING"), + {"mid": m_id, "eid": e_id}, + ) + + # ------------------------------------------------------------------ + # 9. Migrate memberships.schedule JSON: + # [{block: int, duty: str}] -> [{time_block_id: int, duty: str}] + # ------------------------------------------------------------------ + import json + memberships_with_schedule = conn.execute( + text("SELECT id, tournament_id, schedule FROM memberships WHERE schedule IS NOT NULL") + ).fetchall() + for m_id, t_id, schedule_json in memberships_with_schedule: + if not schedule_json: + continue + t_map = old_number_to_id.get(t_id, {}) + new_schedule = [] + changed = False + for entry in schedule_json: + block_num = entry.get("block") + if block_num is not None and block_num in t_map: + new_schedule.append({"time_block_id": t_map[block_num], "duty": entry.get("duty", "")}) + changed = True + else: + new_schedule.append(entry) + if changed: + conn.execute( + text("UPDATE memberships SET schedule = :s WHERE id = :mid"), + {"s": json.dumps(new_schedule), "mid": m_id}, + ) + + # ------------------------------------------------------------------ + # 10. Drop legacy columns + # ------------------------------------------------------------------ + op.drop_column('tournaments', 'blocks') + op.drop_column('events', 'blocks') + op.drop_column('events', 'category') + op.drop_column('memberships', 'assigned_event_id') + + +def downgrade(): + conn = op.get_bind() + + # Re-add legacy columns + op.add_column('memberships', sa.Column('assigned_event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='SET NULL'), nullable=True)) + op.add_column('events', sa.Column('category', sa.String(255), nullable=True)) + op.add_column('events', sa.Column('blocks', sa.JSON(), nullable=True)) + op.add_column('tournaments', sa.Column('blocks', sa.JSON(), nullable=True)) + + # Restore events.category from category_id + conn.execute(text(""" + UPDATE events e + SET category = ( + SELECT tc.name FROM tournament_categories tc WHERE tc.id = e.category_id + ) + WHERE e.category_id IS NOT NULL + """)) + + op.drop_column('events', 'category_id') + + # Drop new tables (cascade handles association tables) + op.drop_table('membership_events') + op.drop_table('event_time_blocks') + op.drop_table('tournament_categories') + op.drop_table('time_blocks') From ea4d36290d0bbcb2211c914c819085bcbd03d40e Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:24:46 -0700 Subject: [PATCH 003/100] fix: remove legacy blocks field and add missing imports in tournament schema - remove TournamentBlock class and blocks field from TournamentBase and TournamentUpdate (column dropped in migration) - remove validate_block_numbers model validator that depended on blocks - add missing TimeBlockRead and TournamentCategoryRead imports to TournamentRead --- backend/app/schemas/tournament.py | 46 ++----------------------------- 1 file changed, 2 insertions(+), 44 deletions(-) diff --git a/backend/app/schemas/tournament.py b/backend/app/schemas/tournament.py index 8154732d..ee86f876 100644 --- a/backend/app/schemas/tournament.py +++ b/backend/app/schemas/tournament.py @@ -2,40 +2,8 @@ from datetime import datetime from pydantic import BaseModel, model_validator, field_validator from app.core.permissions import ALL_PERMISSIONS - - -# --------------------------------------------------------------------------- -# Block schema — validated structure for tournament time blocks -# --------------------------------------------------------------------------- -class TournamentBlock(BaseModel): - number: int - label: str - date: str # "YYYY-MM-DD" — which day this block falls on - start: str # "HH:MM" 24hr format - end: str # "HH:MM" 24hr format - - @field_validator("date") - @classmethod - def validate_date_format(cls, v: str) -> str: - from datetime import date as date_type - try: - date_type.fromisoformat(v) - except ValueError: - raise ValueError("date must be in YYYY-MM-DD format") - return v - - @field_validator("start", "end") - @classmethod - def validate_time_format(cls, v: str) -> str: - parts = v.split(":") - if len(parts) != 2: - raise ValueError("Time must be in HH:MM format") - h, m = parts - if not h.isdigit() or not m.isdigit(): - raise ValueError("Time must be in HH:MM format") - if not (0 <= int(h) <= 23) or not (0 <= int(m) <= 59): - raise ValueError("Invalid time value") - return v +from app.schemas.time_block import TimeBlockRead +from app.schemas.tournament_category import TournamentCategoryRead # --------------------------------------------------------------------------- @@ -116,7 +84,6 @@ class TournamentBase(BaseModel): start_date: datetime | None = None end_date: datetime | None = None location: str | None = None - blocks: list[TournamentBlock] = [] volunteer_schema: VolunteerSchema = VolunteerSchema() @model_validator(mode="after") @@ -126,14 +93,6 @@ def validate_dates(self) -> TournamentBase: raise ValueError("end_date must be after start_date") return self - @model_validator(mode="after") - def validate_block_numbers(self) -> TournamentBase: - if self.blocks: - numbers = [b.number for b in self.blocks] - if len(numbers) != len(set(numbers)): - raise ValueError("Block numbers must be unique") - return self - class TournamentCreate(TournamentBase): pass @@ -145,7 +104,6 @@ class TournamentUpdate(BaseModel): start_date: datetime | None = None end_date: datetime | None = None location: str | None = None - blocks: list[TournamentBlock] | None = None volunteer_schema: VolunteerSchema | None = None From 7b154ae5ec61ff2a5ee2540b859f144a60f3f1b7 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:30:04 -0700 Subject: [PATCH 004/100] test: add test_time_blocks.py and fix conftest tournament fixtures - add 19 tests covering GET/POST/PATCH/DELETE for time blocks - cover ordering by date then start, 409 guard on delete with assigned events, permission checks (manage_events vs view_events), and cross-tournament 404 - remove stale blocks=[] kwarg from td_tournament and other_tournament conftest fixtures (column dropped in migration) --- backend/tests/api/test_time_blocks.py | 227 ++++++++++++++++++++++++++ backend/tests/conftest.py | 4 +- 2 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 backend/tests/api/test_time_blocks.py diff --git a/backend/tests/api/test_time_blocks.py b/backend/tests/api/test_time_blocks.py new file mode 100644 index 00000000..fa70e8f1 --- /dev/null +++ b/backend/tests/api/test_time_blocks.py @@ -0,0 +1,227 @@ +"""Tests for /tournaments/{tournament_id}/blocks endpoints.""" +import pytest +from fastapi.testclient import TestClient +from tests.conftest import login +from app.models.models import Membership + + +def _make_block(client, tournament_id, **overrides): + payload = { + "label": "Block 1", + "date": "2025-03-15", + "start": "09:00", + "end": "11:00", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/blocks/", json=payload) + + +def _make_event(client, tournament_id, **overrides): + payload = {"tournament_id": tournament_id, "name": "Boomilever", "division": "C"} + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/events/", json=payload) + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +def test_create_block(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_block(client, td_tournament.id) + assert response.status_code == 201 + data = response.json() + assert data["label"] == "Block 1" + assert data["date"] == "2025-03-15" + assert data["start"] == "09:00" + assert data["end"] == "11:00" + assert data["tournament_id"] == td_tournament.id + assert "id" in data + + +def test_create_block_invalid_date_format(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_block(client, td_tournament.id, date="15-03-2025") + assert response.status_code == 422 + + +def test_create_block_invalid_time_format(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_block(client, td_tournament.id, start="9:00 AM") + assert response.status_code == 422 + + +def test_create_block_end_before_start_allowed(client, td_user, td_tournament): + """Midnight-spanning blocks (e.g. 23:00–01:00) are explicitly permitted.""" + login(client, "td@test.com", "tdpass") + response = _make_block(client, td_tournament.id, start="23:00", end="01:00") + assert response.status_code == 201 + + +def test_create_block_requires_manage_events(client, td_user, other_tournament, db): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert _make_block(client, other_tournament.id).status_code == 403 + + +def test_create_block_non_member_gets_404(client, td_user, other_tournament): + login(client, "td@test.com", "tdpass") + assert _make_block(client, other_tournament.id).status_code == 404 + + +def test_create_block_unauthenticated(client, td_tournament): + assert _make_block(client, td_tournament.id).status_code == 401 + + +# --------------------------------------------------------------------------- +# List +# --------------------------------------------------------------------------- + +def test_list_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, label="Block A") + _make_block(client, td_tournament.id, label="Block B", start="11:00", end="13:00") + response = client.get(f"/tournaments/{td_tournament.id}/blocks/") + assert response.status_code == 200 + assert len(response.json()) == 2 + + +def test_list_blocks_ordered_by_date_then_start(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, label="Day2 Morning", date="2025-03-16", start="09:00", end="11:00") + _make_block(client, td_tournament.id, label="Day1 Afternoon", date="2025-03-15", start="13:00", end="15:00") + _make_block(client, td_tournament.id, label="Day1 Morning", date="2025-03-15", start="09:00", end="11:00") + labels = [b["label"] for b in client.get(f"/tournaments/{td_tournament.id}/blocks/").json()] + assert labels == ["Day1 Morning", "Day1 Afternoon", "Day2 Morning"] + + +def test_list_blocks_view_events_permission_sufficient( + client, td_user, other_tournament, db +): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/blocks/").status_code == 200 + + +def test_list_blocks_non_member_gets_404(client, td_user, other_tournament): + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/blocks/").status_code == 404 + + +# --------------------------------------------------------------------------- +# PATCH +# --------------------------------------------------------------------------- + +def test_update_block(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_block(client, td_tournament.id).json() + response = client.patch( + f"/tournaments/{td_tournament.id}/blocks/{created['id']}/", + json={"label": "Impound", "start": "08:00"}, + ) + assert response.status_code == 200 + data = response.json() + assert data["label"] == "Impound" + assert data["start"] == "08:00" + assert data["end"] == "11:00" # unchanged + + +def test_update_block_not_found(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert client.patch( + f"/tournaments/{td_tournament.id}/blocks/9999/", json={"label": "X"} + ).status_code == 404 + + +def test_update_block_wrong_tournament_404( + client, td_user, td_tournament, other_user, other_tournament, db +): + """A block belonging to tournament A is not reachable via tournament B's URL.""" + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["tournament_director"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + assert client.patch( + f"/tournaments/{other_tournament.id}/blocks/{block['id']}/", + json={"label": "X"}, + ).status_code == 404 + + +def test_update_block_requires_manage_events(client, td_user, other_tournament, db): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.patch( + f"/tournaments/{other_tournament.id}/blocks/1/", json={"label": "X"} + ).status_code == 403 + + +# --------------------------------------------------------------------------- +# DELETE +# --------------------------------------------------------------------------- + +def test_delete_block(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_block(client, td_tournament.id).json() + assert client.delete( + f"/tournaments/{td_tournament.id}/blocks/{created['id']}/" + ).status_code == 204 + blocks = client.get(f"/tournaments/{td_tournament.id}/blocks/").json() + assert not any(b["id"] == created["id"] for b in blocks) + + +def test_delete_block_not_found(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert client.delete(f"/tournaments/{td_tournament.id}/blocks/9999/").status_code == 404 + + +def test_delete_block_with_events_returns_409(client, td_user, td_tournament): + """Deleting a block that has assigned events must return 409 with affected_events.""" + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + event = _make_event( + client, td_tournament.id, time_block_ids=[block["id"]] + ).json() + + response = client.delete(f"/tournaments/{td_tournament.id}/blocks/{block['id']}/") + assert response.status_code == 409 + detail = response.json()["detail"] + assert "affected_events" in detail + affected_ids = [e["id"] for e in detail["affected_events"]] + assert event["id"] in affected_ids + + +def test_delete_block_requires_manage_events(client, td_user, other_tournament, db): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.delete( + f"/tournaments/{other_tournament.id}/blocks/1/" + ).status_code == 403 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d806aa86..58a7e1bc 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -128,7 +128,7 @@ def td_tournament(db, td_user): tournament = Tournament( name="TD Test Tournament", owner_id=td_user.id, - blocks=[], + volunteer_schema={"custom_fields": [], "positions": DEFAULT_POSITIONS}, ) db.add(tournament) @@ -149,7 +149,7 @@ def other_tournament(db, other_user): tournament = Tournament( name="Other Test Tournament", owner_id=other_user.id, - blocks=[], + volunteer_schema={"custom_fields": [], "positions": DEFAULT_POSITIONS}, ) db.add(tournament) From 251d5898c5fd81d7462dd9da793720fc0fede4fc Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:35:15 -0700 Subject: [PATCH 005/100] test: add test_categories.py and update conftest with category seeding - add 17 tests covering GET/POST/DELETE for tournament categories - cover default seeding on tournament create (via API route), seeded-category guard on delete, 409 guard when events use the category, permission checks, and cross-tournament 404 - add _seed_tournament helper to conftest and apply it to td_tournament and other_tournament fixtures to mirror the real API's seeding behavior - import DEFAULT_CATEGORIES and TournamentCategory into conftest --- backend/tests/api/test_categories.py | 198 +++++++++++++++++++++++++++ backend/tests/conftest.py | 18 ++- 2 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 backend/tests/api/test_categories.py diff --git a/backend/tests/api/test_categories.py b/backend/tests/api/test_categories.py new file mode 100644 index 00000000..8387be9e --- /dev/null +++ b/backend/tests/api/test_categories.py @@ -0,0 +1,198 @@ +"""Tests for /tournaments/{tournament_id}/categories endpoints.""" +import pytest +from fastapi.testclient import TestClient +from tests.conftest import login +from app.models.models import Membership +from app.core.permissions import DEFAULT_CATEGORIES + + +def _make_category(client, tournament_id, name="Robotics"): + return client.post( + f"/tournaments/{tournament_id}/categories/", json={"name": name} + ) + + +def _make_event(client, tournament_id, **overrides): + payload = {"tournament_id": tournament_id, "name": "Boomilever", "division": "C"} + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/events/", json=payload) + + +# --------------------------------------------------------------------------- +# Seeding on tournament create +# --------------------------------------------------------------------------- + +def test_tournament_create_seeds_default_categories(client, td_user): + """Categories must be seeded by the POST /tournaments/ route itself.""" + login(client, "td@test.com", "tdpass") + tournament = client.post("/tournaments/", json={"name": "Seeding Test"}).json() + response = client.get(f"/tournaments/{tournament['id']}/categories/") + assert response.status_code == 200 + names = {c["name"] for c in response.json()} + assert set(DEFAULT_CATEGORIES) <= names + + +def test_seeded_categories_are_not_custom(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + categories = client.get(f"/tournaments/{td_tournament.id}/categories/").json() + seeded = [c for c in categories if c["name"] in DEFAULT_CATEGORIES] + assert len(seeded) == len(DEFAULT_CATEGORIES) + assert all(not c["is_custom"] for c in seeded) + + +# --------------------------------------------------------------------------- +# List +# --------------------------------------------------------------------------- + +def test_list_categories_includes_seeded_and_custom(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_category(client, td_tournament.id, name="Robotics") + categories = client.get(f"/tournaments/{td_tournament.id}/categories/").json() + names = {c["name"] for c in categories} + assert "Robotics" in names + assert set(DEFAULT_CATEGORIES) <= names + + +def test_list_categories_view_events_permission_sufficient( + client, td_user, other_tournament, db +): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/categories/").status_code == 200 + + +def test_list_categories_non_member_gets_404(client, td_user, other_tournament): + login(client, "td@test.com", "tdpass") + assert client.get(f"/tournaments/{other_tournament.id}/categories/").status_code == 404 + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + +def test_create_custom_category(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _make_category(client, td_tournament.id, name="Robotics") + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Robotics" + assert data["is_custom"] is True + assert data["tournament_id"] == td_tournament.id + + +def test_create_category_always_custom(client, td_user, td_tournament): + """is_custom is always forced to True on create regardless of any payload.""" + login(client, "td@test.com", "tdpass") + data = _make_category(client, td_tournament.id).json() + assert data["is_custom"] is True + + +def test_create_duplicate_category_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_category(client, td_tournament.id, name="Robotics") + assert _make_category(client, td_tournament.id, name="Robotics").status_code == 400 + + +def test_create_category_same_name_as_seeded_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert _make_category( + client, td_tournament.id, name=DEFAULT_CATEGORIES[0] + ).status_code == 400 + + +def test_create_category_requires_manage_events( + client, td_user, other_tournament, db +): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert _make_category(client, other_tournament.id).status_code == 403 + + +def test_create_category_unauthenticated(client, td_tournament): + assert _make_category(client, td_tournament.id).status_code == 401 + + +# --------------------------------------------------------------------------- +# Delete +# --------------------------------------------------------------------------- + +def test_delete_custom_category(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_category(client, td_tournament.id).json() + assert client.delete( + f"/tournaments/{td_tournament.id}/categories/{created['id']}/" + ).status_code == 204 + names = {c["name"] for c in client.get(f"/tournaments/{td_tournament.id}/categories/").json()} + assert "Robotics" not in names + + +def test_delete_seeded_category_forbidden(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + categories = client.get(f"/tournaments/{td_tournament.id}/categories/").json() + seeded = next(c for c in categories if not c["is_custom"]) + assert client.delete( + f"/tournaments/{td_tournament.id}/categories/{seeded['id']}/" + ).status_code == 403 + + +def test_delete_category_in_use_returns_409(client, td_user, td_tournament): + """Deleting a category that events are using must return 409.""" + login(client, "td@test.com", "tdpass") + cat = _make_category(client, td_tournament.id, name="Robotics").json() + _make_event(client, td_tournament.id, category_id=cat["id"]) + assert client.delete( + f"/tournaments/{td_tournament.id}/categories/{cat['id']}/" + ).status_code == 409 + + +def test_delete_category_not_found(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert client.delete( + f"/tournaments/{td_tournament.id}/categories/9999/" + ).status_code == 404 + + +def test_delete_category_wrong_tournament_404( + client, td_user, td_tournament, other_user, other_tournament, db +): + """A category from tournament A is not reachable via tournament B's URL.""" + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["tournament_director"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + cat = _make_category(client, td_tournament.id, name="Robotics").json() + assert client.delete( + f"/tournaments/{other_tournament.id}/categories/{cat['id']}/" + ).status_code == 404 + + +def test_delete_category_requires_manage_events( + client, td_user, other_tournament, db +): + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["event_supervisor"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert client.delete( + f"/tournaments/{other_tournament.id}/categories/1/" + ).status_code == 403 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 58a7e1bc..3e77547c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -29,8 +29,8 @@ from app.services.sheets_service import SheetsService from app.services.forms_service import FormsService from app.core.auth import hash_password -from app.core.permissions import DEFAULT_POSITIONS -from app.models.models import Membership, Tournament, User +from app.core.permissions import DEFAULT_POSITIONS, DEFAULT_CATEGORIES +from app.models.models import Membership, Tournament, TournamentCategory, User from app.schemas.sheet_config import ( FormQuestionOption, MappedHeader, @@ -123,16 +123,26 @@ def other_user(db): # Tournament + membership fixtures # --------------------------------------------------------------------------- +def _seed_tournament(db, tournament): + """Seed DEFAULT_CATEGORIES for a tournament, mirroring the POST /tournaments/ route.""" + for cat_name in DEFAULT_CATEGORIES: + db.add(TournamentCategory( + tournament_id=tournament.id, + name=cat_name, + is_custom=False, + )) + + @pytest.fixture def td_tournament(db, td_user): tournament = Tournament( name="TD Test Tournament", owner_id=td_user.id, - volunteer_schema={"custom_fields": [], "positions": DEFAULT_POSITIONS}, ) db.add(tournament) db.flush() + _seed_tournament(db, tournament) db.add(Membership( user_id=td_user.id, tournament_id=tournament.id, @@ -149,11 +159,11 @@ def other_tournament(db, other_user): tournament = Tournament( name="Other Test Tournament", owner_id=other_user.id, - volunteer_schema={"custom_fields": [], "positions": DEFAULT_POSITIONS}, ) db.add(tournament) db.flush() + _seed_tournament(db, tournament) db.add(Membership( user_id=other_user.id, tournament_id=tournament.id, From 219fe62dcb018eab2e319f430e52e8915a6f907c Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:40:06 -0700 Subject: [PATCH 006/100] test: update test_events.py for new schema - replace blocks integer list with time_block_ids in _make_event helper and all payloads - replace category string field with category_id FK in create/update tests - add tests for nullable division, invalid division (422), and same-name/different-division uniqueness - add tests for list filter params: division, type, category_id - add tests for time_block_ids on create and patch (assign, replace, clear) - add test verifying time_block_ids from another tournament are silently ignored - add _make_block and _get_seeded_category_id helpers --- backend/tests/api/test_events.py | 162 +++++++++++++++++++++++++++++-- 1 file changed, 155 insertions(+), 7 deletions(-) diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index e601bccc..895e8afa 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -10,12 +10,28 @@ def _make_event(client, tournament_id, **overrides): "tournament_id": tournament_id, "name": "Boomilever", "division": "C", - "blocks": [1, 2, 3, 4, 5, 6], } payload.update(overrides) return client.post(f"/tournaments/{tournament_id}/events/", json=payload) +def _make_block(client, tournament_id, **overrides): + payload = { + "label": "Block 1", + "date": "2025-03-15", + "start": "09:00", + "end": "11:00", + } + payload.update(overrides) + return client.post(f"/tournaments/{tournament_id}/blocks/", json=payload) + + +def _get_seeded_category_id(client, tournament_id): + """Return the id of the first seeded category for the given tournament.""" + categories = client.get(f"/tournaments/{tournament_id}/categories/").json() + return next(c["id"] for c in categories if not c["is_custom"]) + + # --------------------------------------------------------------------------- # Create # --------------------------------------------------------------------------- @@ -30,20 +46,68 @@ def test_create_event_minimal(client, td_user, td_tournament): assert data["tournament_id"] == td_tournament.id assert data["event_type"] == "standard" assert data["volunteers_needed"] == 2 + assert data["time_blocks"] == [] + + +def test_create_event_no_division(client, td_user, td_tournament): + """division is nullable — non-SO events like impound slots have no division.""" + login(client, "td@test.com", "tdpass") + response = _make_event(client, td_tournament.id, division=None) + assert response.status_code == 201 + assert response.json()["division"] is None + + +def test_create_event_invalid_division(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert _make_event(client, td_tournament.id, division="A").status_code == 422 def test_create_event_full(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") - response = _make_event(client, td_tournament.id, + cat_id = _get_seeded_category_id(client, td_tournament.id) + block = _make_block(client, td_tournament.id).json() + 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], + category_id=cat_id, building="Main Hall", + room="101", floor="1", volunteers_needed=3, + time_block_ids=[block["id"]], ) assert response.status_code == 201 data = response.json() - assert data["category"] == "Technology & Engineering" + assert data["category_id"] == cat_id assert data["volunteers_needed"] == 3 assert data["event_type"] == "trial" + assert len(data["time_blocks"]) == 1 + assert data["time_blocks"][0]["id"] == block["id"] + + +def test_create_event_with_time_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + b1 = _make_block(client, td_tournament.id, label="Morning", start="09:00", end="11:00").json() + b2 = _make_block(client, td_tournament.id, label="Afternoon", start="13:00", end="15:00").json() + response = _make_event(client, td_tournament.id, time_block_ids=[b1["id"], b2["id"]]) + assert response.status_code == 201 + returned_ids = {b["id"] for b in response.json()["time_blocks"]} + assert returned_ids == {b1["id"], b2["id"]} + + +def test_create_event_time_block_from_other_tournament_ignored( + client, td_user, td_tournament, other_user, other_tournament, db +): + """time_block_ids referencing another tournament's blocks are silently ignored.""" + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["tournament_director"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + other_block = _make_block(client, other_tournament.id).json() + response = _make_event(client, td_tournament.id, time_block_ids=[other_block["id"]]) + assert response.status_code == 201 + assert response.json()["time_blocks"] == [] def test_create_event_duplicate_rejected(client, td_user, td_tournament): @@ -52,13 +116,18 @@ def test_create_event_duplicate_rejected(client, td_user, td_tournament): assert _make_event(client, td_tournament.id).status_code == 409 +def test_create_event_same_name_different_division_allowed(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + assert _make_event(client, td_tournament.id, name="Anatomy", division="B").status_code == 201 + assert _make_event(client, td_tournament.id, name="Anatomy", division="C").status_code == 201 + + def test_create_event_tournament_id_mismatch(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") response = client.post(f"/tournaments/{td_tournament.id}/events/", json={ "tournament_id": 9999, "name": "Boomilever", "division": "C", - "blocks": [], }) assert response.status_code == 400 @@ -111,6 +180,42 @@ def test_list_events_ordered_by_division_name(client, td_user, td_tournament): assert names[2] == "Hovercraft" +def test_list_events_filter_by_division(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_event(client, td_tournament.id, name="Anatomy", division="B") + _make_event(client, td_tournament.id, name="Boomilever", division="C") + response = client.get(f"/tournaments/{td_tournament.id}/events/?division=B") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Anatomy" + + +def test_list_events_filter_by_type(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + _make_event(client, td_tournament.id, name="Anatomy", division="B", event_type="standard") + _make_event(client, td_tournament.id, name="Boomilever", division="C", event_type="trial") + response = client.get(f"/tournaments/{td_tournament.id}/events/?type=trial") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Boomilever" + + +def test_list_events_filter_by_category_id(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + categories = client.get(f"/tournaments/{td_tournament.id}/categories/").json() + cat_a = categories[0]["id"] + cat_b = categories[1]["id"] + _make_event(client, td_tournament.id, name="Anatomy", division="B", category_id=cat_a) + _make_event(client, td_tournament.id, name="Boomilever", division="C", category_id=cat_b) + response = client.get(f"/tournaments/{td_tournament.id}/events/?category_id={cat_a}") + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + assert data[0]["name"] == "Anatomy" + + def test_list_events_view_events_permission_sufficient( client, td_user, other_tournament, db ): @@ -179,6 +284,49 @@ def test_update_event(client, td_user, td_tournament): assert response.json()["building"] == "Science Hall" +def test_update_event_time_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + event = _make_event(client, td_tournament.id).json() + assert event["time_blocks"] == [] + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{event['id']}/", + json={"time_block_ids": [block["id"]]}, + ) + assert response.status_code == 200 + assert len(response.json()["time_blocks"]) == 1 + assert response.json()["time_blocks"][0]["id"] == block["id"] + + +def test_update_event_clear_time_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + event = _make_event(client, td_tournament.id, time_block_ids=[block["id"]]).json() + assert len(event["time_blocks"]) == 1 + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{event['id']}/", + json={"time_block_ids": []}, + ) + assert response.status_code == 200 + assert response.json()["time_blocks"] == [] + + +def test_update_event_category_id(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + cat_id = _get_seeded_category_id(client, td_tournament.id) + event = _make_event(client, td_tournament.id).json() + assert event["category_id"] is None + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/{event['id']}/", + json={"category_id": cat_id}, + ) + assert response.status_code == 200 + assert response.json()["category_id"] == cat_id + + def test_update_event_volunteer_cannot_patch( client, td_user, other_user, other_tournament, db ): @@ -215,4 +363,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 From 0e19ebb8b8156ee2ad7c7372682ccb6beee83026 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:47:37 -0700 Subject: [PATCH 007/100] refactor: make manage_events explicit for tournament_director and simplify events write check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add manage_events to tournament_director in DEFAULT_POSITIONS (was implicit via manage_tournament implication) - remove manage_tournament fallback from _require_write_permission in events route — manage_events check alone is sufficient since manage_tournament still implies it via PERMISSION_IMPLICATIONS - remove unused MANAGE_TOURNAMENT import from events.py - add test verifying manage_tournament implies manage_events so tournament directors can still manage events on other tournaments --- backend/app/api/routes/events.py | 8 ++------ backend/app/core/permissions.py | 2 +- backend/tests/api/test_events.py | 17 +++++++++++++++++ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index 15e9793f..a7ec4272 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -5,7 +5,6 @@ from app.core.auth import get_current_user from app.core.permissions import ( MANAGE_EVENTS, - MANAGE_TOURNAMENT, VIEW_EVENTS, require_permission, has_permission, @@ -33,11 +32,8 @@ def _get_event_or_404(event_id: int, tournament_id: int, db: Session) -> Event: def _require_write_permission(user: User, tournament_id: int, db: Session) -> None: - """Raises 403 unless user has manage_events or manage_tournament.""" - if not ( - has_permission(user, tournament_id, MANAGE_EVENTS, db) - or has_permission(user, tournament_id, MANAGE_TOURNAMENT, db) - ): + """Raises 403 unless user has manage_events permission.""" + if not has_permission(user, tournament_id, MANAGE_EVENTS, db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions", diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 65fb32d9..b727cd3b 100644 --- a/backend/app/core/permissions.py +++ b/backend/app/core/permissions.py @@ -74,7 +74,7 @@ { "key": "tournament_director", "label": "Tournament Director", - "permissions": [MANAGE_TOURNAMENT], + "permissions": [MANAGE_TOURNAMENT, MANAGE_EVENTS], }, { "key": "volunteer_coordinator", diff --git a/backend/tests/api/test_events.py b/backend/tests/api/test_events.py index 895e8afa..ac9dc540 100644 --- a/backend/tests/api/test_events.py +++ b/backend/tests/api/test_events.py @@ -141,6 +141,7 @@ def test_create_event_non_member_forbidden(client, td_user, other_tournament): def test_create_event_volunteer_member_forbidden( client, td_user, other_tournament, db ): + """view_events only — cannot write events.""" db.add(Membership( user_id=td_user.id, tournament_id=other_tournament.id, @@ -152,6 +153,22 @@ def test_create_event_volunteer_member_forbidden( assert _make_event(client, other_tournament.id).status_code == 403 +def test_create_event_manage_tournament_implies_manage_events( + client, td_user, other_tournament, db +): + """manage_tournament implies manage_events via PERMISSION_IMPLICATIONS — + a tournament_director on another tournament can manage its events.""" + db.add(Membership( + user_id=td_user.id, + tournament_id=other_tournament.id, + positions=["tournament_director"], + status="confirmed", + )) + db.commit() + login(client, "td@test.com", "tdpass") + assert _make_event(client, other_tournament.id).status_code == 201 + + def test_create_event_unauthenticated(client, td_tournament): assert _make_event(client, td_tournament.id).status_code == 401 From fe15acf38f1eec59f66d6de8a01c55d5078ed5f3 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 15:55:29 -0700 Subject: [PATCH 008/100] test: update test_tournaments.py for new schema - remove SAMPLE_BLOCKS constant and all tests that used the legacy blocks field (test_create_tournament_full blocks assertions, test_create_tournament_duplicate_block_numbers, test_update_tournament_add_blocks) - fix test_create_tournament_minimal to assert time_blocks == [] and correct categories count instead of blocks == [] - update test_create_tournament_full to omit blocks from payload and assert on time_blocks and categories fields - add test_get_tournament_response_includes_time_blocks_and_categories verifying TournamentRead shape - import DEFAULT_CATEGORIES for assertions --- backend/tests/api/test_tournaments.py | 52 ++++++++++----------------- 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/backend/tests/api/test_tournaments.py b/backend/tests/api/test_tournaments.py index 297083b3..6edb05aa 100644 --- a/backend/tests/api/test_tournaments.py +++ b/backend/tests/api/test_tournaments.py @@ -2,20 +2,9 @@ import pytest from fastapi.testclient import TestClient from tests.conftest import login -from app.core.permissions import DEFAULT_POSITIONS +from app.core.permissions import DEFAULT_POSITIONS, DEFAULT_CATEGORIES from app.models.models import Membership, Tournament -SAMPLE_BLOCKS = [ - {"number": 1, "label": "Block 1", "date": "2025-11-15", "start": "08:00", "end": "09:00"}, - {"number": 2, "label": "Block 2", "date": "2025-11-15", "start": "09:15", "end": "10:15"}, - {"number": 3, "label": "Block 3", "date": "2025-11-15", "start": "10:30", "end": "11:30"}, - {"number": 4, "label": "Block 4", "date": "2025-11-15", "start": "12:30", "end": "13:30"}, - {"number": 5, "label": "Block 5", "date": "2025-11-15", "start": "13:45", "end": "14:45"}, - {"number": 6, "label": "Block 6", "date": "2025-11-15", "start": "15:00", "end": "16:00"}, - {"number": 7, "label": "Scoring", "date": "2025-11-15", "start": "16:15", "end": "17:15"}, - {"number": 8, "label": "Awards", "date": "2025-11-15", "start": "17:30", "end": "18:30"}, -] - SAMPLE_VOLUNTEER_SCHEMA = { "custom_fields": [ {"key": "transportation", "label": "How will you get there?", "type": "string"}, @@ -103,7 +92,8 @@ def test_create_tournament_minimal(client, td_user): assert response.status_code == 201 data = response.json() assert data["name"] == "Minimal Tournament" - assert data["blocks"] == [] + assert data["time_blocks"] == [] + assert len(data["categories"]) == len(DEFAULT_CATEGORIES) def test_create_tournament_auto_populates_default_positions(client, td_user): @@ -137,14 +127,15 @@ def test_create_tournament_full(client, td_user): "start_date": "2025-05-21T08:00:00", "end_date": "2025-05-23T18:00:00", "location": "USC", - "blocks": SAMPLE_BLOCKS, "volunteer_schema": SAMPLE_VOLUNTEER_SCHEMA, }) assert response.status_code == 201 data = response.json() assert data["name"] == "Nationals 2025" - assert len(data["blocks"]) == 8 + assert data["location"] == "USC" assert len(data["volunteer_schema"]["custom_fields"]) == 3 + assert data["time_blocks"] == [] + assert len(data["categories"]) == len(DEFAULT_CATEGORIES) def test_create_tournament_invalid_dates(client, td_user): @@ -156,17 +147,6 @@ def test_create_tournament_invalid_dates(client, td_user): }).status_code == 422 -def test_create_tournament_duplicate_block_numbers(client, td_user): - login(client, "td@test.com", "tdpass") - assert client.post("/tournaments/", json={ - "name": "Bad Blocks", - "blocks": [ - {"number": 1, "label": "B1", "date": "2025-11-15", "start": "08:00", "end": "09:00"}, - {"number": 1, "label": "B1 Again", "date": "2025-11-15", "start": "09:00", "end": "10:00"}, - ] - }).status_code == 422 - - def test_create_tournament_unauthenticated(client): assert client.post("/tournaments/", json={"name": "Sneaky"}).status_code == 401 @@ -182,6 +162,19 @@ def test_get_tournament_member_can_access(client, td_user, td_tournament): assert response.json()["name"] == td_tournament.name +def test_get_tournament_response_includes_time_blocks_and_categories( + client, td_user, td_tournament +): + """TournamentRead must include time_blocks list and seeded categories.""" + login(client, "td@test.com", "tdpass") + data = client.get(f"/tournaments/{td_tournament.id}/").json() + assert "time_blocks" in data + assert isinstance(data["time_blocks"], list) + assert "categories" in data + category_names = {c["name"] for c in data["categories"]} + assert set(DEFAULT_CATEGORIES) <= category_names + + def test_get_tournament_non_member_gets_404(client, td_user, other_tournament): login(client, "td@test.com", "tdpass") assert client.get(f"/tournaments/{other_tournament.id}/").status_code == 404 @@ -245,13 +238,6 @@ def test_update_tournament_non_member_gets_404(client, td_user, other_tournament ).status_code == 404 -def test_update_tournament_add_blocks(client, td_user, td_tournament): - login(client, "td@test.com", "tdpass") - response = client.patch(f"/tournaments/{td_tournament.id}/", json={"blocks": SAMPLE_BLOCKS}) - assert response.status_code == 200 - assert len(response.json()["blocks"]) == 8 - - def test_update_tournament_positions(client, td_user, td_tournament): login(client, "td@test.com", "tdpass") custom_positions = [ From e8255c9f2f0f647d0432085ae0472f2b44aaa4f8 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:04:50 -0700 Subject: [PATCH 009/100] fix: remove assigned_event_id and update ScheduleSlot to use time_block_id - remove assigned_event_id from MembershipBase, MembershipUpdate schemas and _serialize/create/update route handlers - remove Event import from memberships route (no longer used) - update ScheduleSlot.block -> ScheduleSlot.time_block_id (FK into time_blocks table) - update test_memberships.py: replace _make_event helper with _make_block, update schedule payloads to time_block_id format, remove assigned_event_id from full create test --- backend/app/api/routes/memberships.py | 19 +--------------- backend/app/schemas/membership.py | 10 ++++----- backend/tests/api/test_memberships.py | 31 ++++++++++++++------------- 3 files changed, 21 insertions(+), 39 deletions(-) diff --git a/backend/app/api/routes/memberships.py b/backend/app/api/routes/memberships.py index 7409cdc0..dcd470d4 100644 --- a/backend/app/api/routes/memberships.py +++ b/backend/app/api/routes/memberships.py @@ -10,7 +10,7 @@ require_permission, ) from app.db.session import get_db -from app.models.models import Event, Membership, Tournament, User +from app.models.models import Membership, Tournament, User from app.schemas.membership import MembershipCreate, MembershipRead, MembershipUpdate, MembershipReadFlat # Routes nested: /tournaments/{tournament_id}/memberships/... @@ -27,7 +27,6 @@ def _serialize(m: Membership, include_user: bool = False) -> dict: "id": m.id, "user_id": m.user_id, "tournament_id": m.tournament_id, - "assigned_event_id": m.assigned_event_id, "positions": m.positions, "schedule": m.schedule, "status": m.status, @@ -180,14 +179,6 @@ def create_membership( if not tournament: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tournament not found") - if payload.assigned_event_id: - event = db.query(Event).filter( - Event.id == payload.assigned_event_id, - Event.tournament_id == tournament_id, - ).first() - if not event: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found in this tournament") - existing = db.query(Membership).filter( Membership.user_id == payload.user_id, Membership.tournament_id == tournament_id, @@ -226,14 +217,6 @@ def update_membership( _require_write_permission(current_user, tournament_id, db) m = _get_membership_or_404(membership_id, tournament_id, db) - if payload.assigned_event_id is not None: - event = db.query(Event).filter( - Event.id == payload.assigned_event_id, - Event.tournament_id == tournament_id, - ).first() - if not event: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Event not found in this tournament") - update_data = payload.model_dump(exclude_none=True) if "availability" in update_data and payload.availability: diff --git a/backend/app/schemas/membership.py b/backend/app/schemas/membership.py index ea11086e..f0a79b99 100644 --- a/backend/app/schemas/membership.py +++ b/backend/app/schemas/membership.py @@ -16,22 +16,21 @@ class AvailabilitySlot(BaseModel): class ScheduleSlot(BaseModel): """A single day-of block assignment.""" - block: int # block number - duty: str # position key or free string, e.g. "event_supervisor" + time_block_id: int # FK into time_blocks table + duty: str # position key or free string, e.g. "event_supervisor" class MembershipBase(BaseModel): user_id: int tournament_id: int - assigned_event_id: int | None = None # Position keys from tournament.volunteer_schema["positions"]. # Drives both title and system permissions within this tournament. # e.g. ["lead_event_supervisor", "test_writer"] positions: list[str] | None = None - # Day-of block schedule — one entry per block. - # e.g. [{"block": 1, "duty": "event_supervisor"}, {"block": 7, "duty": "scoring"}] + # Day-of block schedule — one entry per time block. + # e.g. [{"time_block_id": 3, "duty": "event_supervisor"}, {"time_block_id": 7, "duty": "scoring"}] schedule: list[ScheduleSlot] | None = None status: str = "interested" @@ -78,7 +77,6 @@ class MembershipCreate(MembershipBase): class MembershipUpdate(BaseModel): """Partial update — TD/coordinator manual override for any field.""" - assigned_event_id: int | None = None positions: list[str] | None = None schedule: list[ScheduleSlot] | None = None status: str | None = None diff --git a/backend/tests/api/test_memberships.py b/backend/tests/api/test_memberships.py index b586271a..8638f794 100644 --- a/backend/tests/api/test_memberships.py +++ b/backend/tests/api/test_memberships.py @@ -15,12 +15,9 @@ def _make_user(db, email="alice@example.com"): return {"id": user.id, "email": user.email} -def _make_event(client, tournament_id): - return client.post(f"/tournaments/{tournament_id}/events/", json={ - "tournament_id": tournament_id, - "name": "Boomilever", - "division": "C", - "blocks": [1, 2, 3, 4, 5, 6], +def _make_block(client, tournament_id): + return client.post(f"/tournaments/{tournament_id}/blocks/", json={ + "label": "Block 1", "date": "2025-03-15", "start": "09:00", "end": "11:00", }).json() @@ -61,29 +58,31 @@ def test_create_membership_with_positions(client, td_user, td_tournament, db): def test_create_membership_with_schedule(client, td_user, td_tournament, db): u = _make_user(db) login(client, "td@test.com", "tdpass") + b1 = _make_block(client, td_tournament.id) + b2 = _make_block(client, td_tournament.id) response = _make_membership( client, td_tournament.id, u["id"], schedule=[ - {"block": 1, "duty": "event_supervisor"}, - {"block": 7, "duty": "scoring"}, + {"time_block_id": b1["id"], "duty": "event_supervisor"}, + {"time_block_id": b2["id"], "duty": "scoring"}, ], ) assert response.status_code == 201 schedule = response.json()["schedule"] assert len(schedule) == 2 - assert schedule[1] == {"block": 7, "duty": "scoring"} + assert schedule[1]["duty"] == "scoring" + assert schedule[1]["time_block_id"] == b2["id"] def test_create_membership_full(client, td_user, td_tournament, db): u = _make_user(db) login(client, "td@test.com", "tdpass") - e = _make_event(client, td_tournament.id) + block = _make_block(client, td_tournament.id) response = _make_membership( client, td_tournament.id, u["id"], - assigned_event_id=e["id"], status="assigned", positions=["lead_event_supervisor"], - schedule=[{"block": 1, "duty": "event_supervisor"}], + schedule=[{"time_block_id": block["id"], "duty": "event_supervisor"}], role_preference=["event_volunteer"], event_preference=["Boomilever"], availability=[{"date": "2026-05-21", "start": "08:00", "end": "10:00"}], @@ -94,7 +93,6 @@ def test_create_membership_full(client, td_user, td_tournament, db): assert response.status_code == 201 data = response.json() assert data["positions"] == ["lead_event_supervisor"] - assert data["assigned_event_id"] == e["id"] assert data["extra_data"]["transportation"] == "Driving" assert data["extra_data"]["general_volunteer_interest"] == ["STEM Expo"] @@ -239,16 +237,19 @@ def test_update_membership_positions(client, td_user, td_tournament, db): def test_update_membership_schedule(client, td_user, td_tournament, db): u = _make_user(db) login(client, "td@test.com", "tdpass") + b1 = _make_block(client, td_tournament.id) + b2 = _make_block(client, td_tournament.id) created = _make_membership(client, td_tournament.id, u["id"]).json() response = client.patch( f"/tournaments/{td_tournament.id}/memberships/{created['id']}/", json={"schedule": [ - {"block": 1, "duty": "event_supervisor"}, - {"block": 7, "duty": "scoring"}, + {"time_block_id": b1["id"], "duty": "event_supervisor"}, + {"time_block_id": b2["id"], "duty": "scoring"}, ]}, ) assert response.status_code == 200 assert response.json()["schedule"][1]["duty"] == "scoring" + assert response.json()["schedule"][1]["time_block_id"] == b2["id"] def test_update_membership_extra_data_merges(client, td_user, td_tournament, db): From a9d0e4f40d97681c1eae07fbba48d716642b9a98 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:11:04 -0700 Subject: [PATCH 010/100] fix: update sync service and test_sync.py for time_blocks schema - replace tournament.blocks dict access with tournament.time_blocks ORM relationship in _parse_day_string (availability date resolution) - remove NATS_BLOCKS constant and blocks field from _make_tournament in test_sync.py (column dropped from Tournament) --- backend/app/services/sync_service.py | 4 ++-- backend/tests/api/test_sync.py | 8 -------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/backend/app/services/sync_service.py b/backend/app/services/sync_service.py index 42133561..389e30b6 100644 --- a/backend/app/services/sync_service.py +++ b/backend/app/services/sync_service.py @@ -136,8 +136,8 @@ def _parse_day_string(day_str: str, tournament: Tournament) -> str | None: if month is None or day is None: return None - for block in (tournament.blocks or []): - block_date_str = block.get("date", "") + for block in (tournament.time_blocks or []): + block_date_str = block.date if hasattr(block, "date") else block.get("date", "") if not block_date_str: continue try: diff --git a/backend/tests/api/test_sync.py b/backend/tests/api/test_sync.py index a4f1d681..dcf029a9 100644 --- a/backend/tests/api/test_sync.py +++ b/backend/tests/api/test_sync.py @@ -6,13 +6,6 @@ FAKE_URL = "https://docs.google.com/spreadsheets/d/fake123/edit" -NATS_BLOCKS = [ - {"number": 1, "label": "Thu Check-in", "date": "2026-05-21", "start": "08:00", "end": "10:00"}, - {"number": 2, "label": "Thu Morning", "date": "2026-05-21", "start": "10:00", "end": "12:00"}, - {"number": 3, "label": "Fri Check-in", "date": "2026-05-22", "start": "08:00", "end": "10:00"}, - {"number": 14, "label": "Sat Block 1", "date": "2026-05-23", "start": "08:00", "end": "09:00"}, -] - COLUMN_MAPPINGS = { "Timestamp": {"field": "__ignore__", "field_type": "ignore"}, "Email Address": {"field": "email", "field_type": "single", "value_type": "text"}, @@ -42,7 +35,6 @@ def _make_tournament(client): "name": "2026 Nationals", "start_date": "2026-05-21T08:00:00", "end_date": "2026-05-23T18:00:00", - "blocks": NATS_BLOCKS, "volunteer_schema": {"custom_fields": []}, }).json() From a180b019dee48d9b19ac6c0de6d690cea409ece3 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:15:57 -0700 Subject: [PATCH 011/100] fix: update init_db seed_dev_data for time_blocks and categories schema - remove legacy blocks=[] JSON from Tournament constructor - seed DEFAULT_CATEGORIES via TournamentCategory rows after tournament flush - seed sample time blocks as TimeBlock rows instead of JSON on the tournament - import TournamentCategory, TimeBlock, and DEFAULT_CATEGORIES --- backend/app/db/init_db.py | 44 +++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index f989a767..63d13ebf 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -33,9 +33,9 @@ def seed_dev_data(db: Session) -> None: Idempotent — skips if admin already exists. """ - from app.models.models import Membership, Tournament, User + from app.models.models import Membership, Tournament, TournamentCategory, TimeBlock, User from app.core.auth import hash_password - from app.core.permissions import DEFAULT_POSITIONS + from app.core.permissions import DEFAULT_POSITIONS, DEFAULT_CATEGORIES from datetime import datetime # Skip if already seeded @@ -75,23 +75,41 @@ def seed_dev_data(db: Session) -> None: end_date=datetime(2026, 5, 23, 18, 0), location="University of Southern California", owner_id=td.id, - blocks=[ - {"number": 1, "label": "Block 1", "date": "2026-05-23", "start": "08:00", "end": "09:00"}, - {"number": 2, "label": "Block 2", "date": "2026-05-23", "start": "09:15", "end": "10:15"}, - {"number": 3, "label": "Block 3", "date": "2026-05-23", "start": "10:30", "end": "11:30"}, - {"number": 4, "label": "Block 4", "date": "2026-05-23", "start": "12:30", "end": "13:30"}, - {"number": 5, "label": "Block 5", "date": "2026-05-23", "start": "13:45", "end": "14:45"}, - {"number": 6, "label": "Block 6", "date": "2026-05-23", "start": "15:00", "end": "16:00"}, - {"number": 7, "label": "Scoring", "date": "2026-05-23", "start": "16:15", "end": "17:15"}, - {"number": 8, "label": "Awards", "date": "2026-05-23", "start": "17:30", "end": "18:30"}, - ], volunteer_schema={ "custom_fields": [], "positions": DEFAULT_POSITIONS, }, ) db.add(tournament) - db.flush() # get tournament.id before creating memberships + db.flush() # get tournament.id before creating time_blocks, categories, memberships + + # Seed default categories + for cat_name in DEFAULT_CATEGORIES: + db.add(TournamentCategory( + tournament_id=tournament.id, + name=cat_name, + is_custom=False, + )) + + # Seed sample time blocks + sample_blocks = [ + ("Block 1", "2026-05-23", "08:00", "09:00"), + ("Block 2", "2026-05-23", "09:15", "10:15"), + ("Block 3", "2026-05-23", "10:30", "11:30"), + ("Block 4", "2026-05-23", "12:30", "13:30"), + ("Block 5", "2026-05-23", "13:45", "14:45"), + ("Block 6", "2026-05-23", "15:00", "16:00"), + ("Scoring", "2026-05-23", "16:15", "17:15"), + ("Awards", "2026-05-23", "17:30", "18:30"), + ] + for label, date, start, end in sample_blocks: + db.add(TimeBlock( + tournament_id=tournament.id, + label=label, + date=date, + start=start, + end=end, + )) # TD membership for the regular user — full manage_tournament access td_membership = Membership( From dbb11e8f2046e2ba0f2b707fab4bfe561c5d7c0f Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:18:21 -0700 Subject: [PATCH 012/100] fix: update test_sync_service.py mock to use time_blocks instead of blocks - rename t.blocks to t.time_blocks in _make_tournament helper to match the renamed ORM relationship --- backend/tests/services/test_sync_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/services/test_sync_service.py b/backend/tests/services/test_sync_service.py index 1e114c77..df744dd1 100644 --- a/backend/tests/services/test_sync_service.py +++ b/backend/tests/services/test_sync_service.py @@ -17,7 +17,7 @@ def _make_tournament(blocks=None, start_date=None): t = MagicMock() - t.blocks = blocks or [] + t.time_blocks = blocks or [] t.start_date = start_date return t From 158b0d7502e8344724914710bde11cb1519df76b Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:34:32 -0700 Subject: [PATCH 013/100] feat: add overlap validation to time blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reject overlapping time blocks on POST and PATCH with 409 and conflict detail - handle midnight-spanning blocks via sub-range splitting in _intervals_overlap - exclude the block being updated from its own overlap check (self-exclusion) - add 6 overlap tests: overlap→409, adjacent→201, PATCH overlap→409, self-exclusion→201, midnight-spanning→201, overlap with midnight-spanning→409 - fix test_memberships.py _make_block helper to create non-overlapping blocks --- backend/app/api/routes/time_blocks.py | 70 +++++++++++++++++++++++++++ backend/tests/api/test_memberships.py | 12 ++--- backend/tests/api/test_time_blocks.py | 64 ++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 6 deletions(-) diff --git a/backend/app/api/routes/time_blocks.py b/backend/app/api/routes/time_blocks.py index 33c1594e..6cedade8 100644 --- a/backend/app/api/routes/time_blocks.py +++ b/backend/app/api/routes/time_blocks.py @@ -8,6 +8,68 @@ router = APIRouter(prefix="/tournaments/{tournament_id}/blocks", tags=["time-blocks"]) +def _to_minutes(t: str) -> int: + """Convert 'HH:MM' to minutes since midnight.""" + h, m = t.split(":") + return int(h) * 60 + int(m) + + +def _intervals_overlap(s1: int, e1: int, s2: int, e2: int) -> bool: + """Return True if two time intervals overlap on a 24-hour clock. + + Midnight-spanning blocks have end <= start. Each is split into up to two + sub-ranges so wrap-around is handled correctly. Adjacent blocks + (end == other.start) do NOT overlap. + """ + def to_ranges(s: int, e: int) -> list[tuple[int, int]]: + return [(s, 1440), (0, e)] if e <= s else [(s, e)] + + for a, b in to_ranges(s1, e1): + for c, d in to_ranges(s2, e2): + if a < d and b > c: + return True + return False + + +def _check_overlap( + db: Session, + tournament_id: int, + date: str, + start: str, + end: str, + exclude_id: int | None = None, +) -> None: + """Raise 409 if the given block overlaps any existing block on the same date.""" + cand_start = _to_minutes(start) + cand_end = _to_minutes(end) + + query = db.query(TimeBlock).filter( + TimeBlock.tournament_id == tournament_id, + TimeBlock.date == date, + ) + if exclude_id is not None: + query = query.filter(TimeBlock.id != exclude_id) + + for block in query.all(): + b_start = _to_minutes(block.start) + b_end = _to_minutes(block.end) + + if _intervals_overlap(cand_start, cand_end, b_start, b_end): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": "Time block overlaps with an existing block.", + "conflict": { + "id": block.id, + "label": block.label, + "date": block.date, + "start": block.start, + "end": block.end, + }, + }, + ) + + @router.get("/", response_model=list[TimeBlockRead]) def list_time_blocks( tournament_id: int, @@ -31,6 +93,7 @@ def create_time_block( current_user: User = Depends(require_permission(MANAGE_EVENTS)), ): """Create a new time block.""" + _check_overlap(db, tournament_id, payload.date, payload.start, payload.end) db_block = TimeBlock(**payload.model_dump(), tournament_id=tournament_id) db.add(db_block) db.commit() @@ -56,6 +119,13 @@ def update_time_block( raise HTTPException(status_code=404, detail="Time block not found") update_data = payload.model_dump(exclude_none=True) + + # Determine the effective date/start/end after applying the patch + effective_date = update_data.get("date", db_block.date) + effective_start = update_data.get("start", db_block.start) + effective_end = update_data.get("end", db_block.end) + _check_overlap(db, tournament_id, effective_date, effective_start, effective_end, exclude_id=block_id) + for field, value in update_data.items(): setattr(db_block, field, value) diff --git a/backend/tests/api/test_memberships.py b/backend/tests/api/test_memberships.py index 8638f794..75853ac9 100644 --- a/backend/tests/api/test_memberships.py +++ b/backend/tests/api/test_memberships.py @@ -15,9 +15,9 @@ def _make_user(db, email="alice@example.com"): return {"id": user.id, "email": user.email} -def _make_block(client, tournament_id): +def _make_block(client, tournament_id, label="Block 1", start="09:00", end="11:00"): return client.post(f"/tournaments/{tournament_id}/blocks/", json={ - "label": "Block 1", "date": "2025-03-15", "start": "09:00", "end": "11:00", + "label": label, "date": "2025-03-15", "start": start, "end": end, }).json() @@ -58,8 +58,8 @@ def test_create_membership_with_positions(client, td_user, td_tournament, db): def test_create_membership_with_schedule(client, td_user, td_tournament, db): u = _make_user(db) login(client, "td@test.com", "tdpass") - b1 = _make_block(client, td_tournament.id) - b2 = _make_block(client, td_tournament.id) + b1 = _make_block(client, td_tournament.id, label="Block 1", start="09:00", end="11:00") + b2 = _make_block(client, td_tournament.id, label="Block 2", start="11:00", end="13:00") response = _make_membership( client, td_tournament.id, u["id"], schedule=[ @@ -237,8 +237,8 @@ def test_update_membership_positions(client, td_user, td_tournament, db): def test_update_membership_schedule(client, td_user, td_tournament, db): u = _make_user(db) login(client, "td@test.com", "tdpass") - b1 = _make_block(client, td_tournament.id) - b2 = _make_block(client, td_tournament.id) + b1 = _make_block(client, td_tournament.id, label="Block 1", start="09:00", end="11:00") + b2 = _make_block(client, td_tournament.id, label="Block 2", start="11:00", end="13:00") created = _make_membership(client, td_tournament.id, u["id"]).json() response = client.patch( f"/tournaments/{td_tournament.id}/memberships/{created['id']}/", diff --git a/backend/tests/api/test_time_blocks.py b/backend/tests/api/test_time_blocks.py index fa70e8f1..deaa1d6e 100644 --- a/backend/tests/api/test_time_blocks.py +++ b/backend/tests/api/test_time_blocks.py @@ -225,3 +225,67 @@ def test_delete_block_requires_manage_events(client, td_user, other_tournament, assert client.delete( f"/tournaments/{other_tournament.id}/blocks/1/" ).status_code == 403 + + +# --------------------------------------------------------------------------- +# Overlap validation +# --------------------------------------------------------------------------- + +def test_create_block_overlap_returns_409(client, td_user, td_tournament): + """Creating a block whose time range overlaps an existing block returns 409.""" + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, start="09:00", end="11:00") + response = _make_block(client, td_tournament.id, start="10:00", end="12:00") + assert response.status_code == 409 + detail = response.json()["detail"] + assert "conflict" in detail + assert detail["conflict"]["start"] == "09:00" + + +def test_create_block_adjacent_allowed(client, td_user, td_tournament): + """Blocks that share only an endpoint (end == other.start) do not overlap.""" + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, start="09:00", end="11:00") + response = _make_block(client, td_tournament.id, label="Block 2", start="11:00", end="13:00") + assert response.status_code == 201 + + +def test_patch_block_to_overlap_returns_409(client, td_user, td_tournament): + """PATCHing a block so it overlaps another block returns 409.""" + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, label="Block 1", start="09:00", end="11:00") + b2 = _make_block(client, td_tournament.id, label="Block 2", start="13:00", end="15:00").json() + response = client.patch( + f"/tournaments/{td_tournament.id}/blocks/{b2['id']}/", + json={"start": "10:00", "end": "14:00"}, + ) + assert response.status_code == 409 + assert "conflict" in response.json()["detail"] + + +def test_patch_block_self_exclusion_allowed(client, td_user, td_tournament): + """PATCHing a block with only label/non-time changes does not conflict with itself.""" + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id, start="09:00", end="11:00").json() + response = client.patch( + f"/tournaments/{td_tournament.id}/blocks/{block['id']}/", + json={"label": "Renamed Block"}, + ) + assert response.status_code == 200 + assert response.json()["label"] == "Renamed Block" + + +def test_create_midnight_spanning_block_allowed(client, td_user, td_tournament): + """A midnight-spanning block (end < start) that does not overlap anything is allowed.""" + login(client, "td@test.com", "tdpass") + response = _make_block(client, td_tournament.id, start="22:00", end="00:30") + assert response.status_code == 201 + + +def test_create_block_overlapping_midnight_spanning_returns_409(client, td_user, td_tournament): + """A block that falls within the range of a midnight-spanning block returns 409.""" + login(client, "td@test.com", "tdpass") + _make_block(client, td_tournament.id, label="Late Block", start="23:00", end="01:00") + # 00:00–00:30 falls inside 23:00–01:00 (normalized 23:00–25:00) + response = _make_block(client, td_tournament.id, label="Overlap Block", start="00:00", end="00:30") + assert response.status_code == 409 From 4a75a45c9ecead23b95a0dbae39f3f7b19e71f72 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:45:54 -0700 Subject: [PATCH 014/100] feat: update api.ts types for events/time-blocks/categories schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add TimeBlock, TimeBlockCreate, TimeBlockConflict interfaces and timeBlocksApi (CRUD under /tournaments/{id}/blocks/) - Add TournamentCategory interface and categoriesApi (list, create, delete under /tournaments/{id}/categories/) - Update Event interface: division nullable, category_id replaces category string, time_block_ids + time_blocks[] replace blocks[] - Add EventCreate interface for typed create/update payloads; add query param support to listByTournament - Update Tournament.blocks → time_blocks: TimeBlock[] - Update ScheduleSlot.block → time_block_id; Membership.assigned_event_id → event_ids: number[] --- frontend/lib/api.ts | 115 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 93 insertions(+), 22 deletions(-) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2613ae1c..b7af3dd9 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -84,16 +84,68 @@ export const authApi = { } // ------------------------------------------------------------------------- -// Tournaments +// Time Blocks — nested under /tournaments/{id}/blocks/ // ------------------------------------------------------------------------- -export interface TournamentBlock { - number: number - label: string - date: string // YYYY-MM-DD - start: string // HH:MM - end: string // HH:MM +export interface TimeBlock { + id: number + tournament_id: number + label: string + date: string // YYYY-MM-DD + start: string // HH:MM 24hr + end: string // HH:MM 24hr + created_at: string + updated_at: string +} + +export interface TimeBlockCreate { + label: string + date: string + start: string + end: string } +export interface TimeBlockConflict { + id: number + label: string + date: string + start: string + end: string +} + +export const timeBlocksApi = { + listByTournament: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/blocks/`), + create: (tournamentId: number, body: TimeBlockCreate) => + api.post(`/tournaments/${tournamentId}/blocks/`, body), + update: (tournamentId: number, id: number, body: Partial) => + api.patch(`/tournaments/${tournamentId}/blocks/${id}/`, body), + delete: (tournamentId: number, id: number) => + api.delete(`/tournaments/${tournamentId}/blocks/${id}/`), +} + +// ------------------------------------------------------------------------- +// Tournament Categories — nested under /tournaments/{id}/categories/ +// ------------------------------------------------------------------------- +export interface TournamentCategory { + id: number + tournament_id: number + name: string + is_custom: boolean + created_at: string +} + +export const categoriesApi = { + listByTournament: (tournamentId: number) => + api.get(`/tournaments/${tournamentId}/categories/`), + create: (tournamentId: number, name: string) => + api.post(`/tournaments/${tournamentId}/categories/`, { name }), + delete: (tournamentId: number, id: number) => + api.delete(`/tournaments/${tournamentId}/categories/${id}/`), +} + +// ------------------------------------------------------------------------- +// Tournaments +// ------------------------------------------------------------------------- export interface CustomField { key: string label: string @@ -117,7 +169,7 @@ export interface Tournament { start_date: string | null end_date: string | null location: string | null - blocks: TournamentBlock[] + time_blocks: TimeBlock[] volunteer_schema: VolunteerSchema owner_id: number created_at: string @@ -140,26 +192,45 @@ export interface Event { id: number tournament_id: number name: string - division: 'B' | 'C' + division: 'B' | 'C' | null event_type: 'standard' | 'trial' - category: string | null + category_id: number | null building: string | null room: string | null floor: string | null volunteers_needed: number - blocks: number[] + time_block_ids: number[] + time_blocks: TimeBlock[] created_at: string updated_at: string } +export interface EventCreate { + name: string + division?: 'B' | 'C' | null + event_type?: 'standard' | 'trial' + category_id?: number | null + building?: string | null + room?: string | null + floor?: string | null + volunteers_needed?: number + time_block_ids?: number[] +} + export const eventsApi = { - listByTournament: (tournamentId: number) => - api.get(`/tournaments/${tournamentId}/events/`), + listByTournament: (tournamentId: number, params?: { category_id?: number; division?: string; type?: string }) => { + const qs = params ? '?' + new URLSearchParams( + Object.entries(params) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ).toString() : '' + return api.get(`/tournaments/${tournamentId}/events/${qs}`) + }, get: (tournamentId: number, id: number) => api.get(`/tournaments/${tournamentId}/events/${id}/`), - create: (tournamentId: number, body: Partial) => + create: (tournamentId: number, body: EventCreate) => 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}/`), @@ -210,16 +281,16 @@ export interface AvailabilitySlot { } export interface ScheduleSlot { - block: number - duty: string + time_block_id: number + duty: string } export interface Membership { - id: number - user_id: number - tournament_id: number - assigned_event_id: number | null - positions: string[] | null + id: number + user_id: number + tournament_id: number + event_ids: number[] + positions: string[] | null schedule: ScheduleSlot[] | null status: MembershipStatus role_preference: string[] | null From 6fd0fba3cbc475ee59d3adf4c2547ae65a8fe184 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:51:33 -0700 Subject: [PATCH 015/100] feat: add event/category/division color tokens to globals.css - Add 5 category color ramps (--color-cat-1 through --color-cat-5) each with main, subtle, and text variants - Add division chip colors for B (blue), C (green), and no-division (purple) - Add event type chip colors for standard (teal) and trial (amber) - Token naming follows existing --color-* pattern for consistency --- frontend/app/globals.css | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ace8afd3..becf9252 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -122,6 +122,50 @@ --color-status-assigned: #1D4ED8; --color-status-removed: #9B9B93; + /* Category color ramps (5 rotating slots, assigned by category index) + Each has: main border/icon color, subtle background fill, foreground text */ + --color-cat-1: #2563EB; /* blue — Life, Personal & Social Science */ + --color-cat-1-subtle: #EFF6FF; + --color-cat-1-text: #1E40AF; + + --color-cat-2: #16A34A; /* green — Earth and Space Science */ + --color-cat-2-subtle: #F0FDF4; + --color-cat-2-text: #15803D; + + --color-cat-3: #D97706; /* amber — Physical Science & Chemistry */ + --color-cat-3-subtle: #FFFBEB; + --color-cat-3-text: #B45309; + + --color-cat-4: #7C3AED; /* violet — Technology & Engineering */ + --color-cat-4-subtle: #F5F3FF; + --color-cat-4-text: #6D28D9; + + --color-cat-5: #0891B2; /* cyan — Inquiry & Nature of Science */ + --color-cat-5-subtle: #ECFEFF; + --color-cat-5-text: #0E7490; + + /* Division chip colors */ + --color-div-b: #2563EB; + --color-div-b-subtle: #EFF6FF; + --color-div-b-text: #1E40AF; + + --color-div-c: #16A34A; + --color-div-c-subtle: #F0FDF4; + --color-div-c-text: #15803D; + + --color-div-none: #7C3AED; + --color-div-none-subtle: #F5F3FF; + --color-div-none-text: #6D28D9; + + /* Event type chip colors */ + --color-type-standard: #0891B2; + --color-type-standard-subtle: #ECFEFF; + --color-type-standard-text: #0E7490; + + --color-type-trial: #D97706; + --color-type-trial-subtle: #FFFBEB; + --color-type-trial-text: #B45309; + /* Typography --font-mono → Geist Mono (body text, inputs, data values, code) --font-sans → Geist Sans (UI labels, buttons, nav, subheadings) From 8c67d84728617b28653d610301f4dfb847a341cb Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:54:05 -0700 Subject: [PATCH 016/100] feat: build events page shell with tabs, import bar, and data fetching - Add PageHeader with title and subtitle - Add TabBar with Timeline / Cards / Time Blocks tabs (active underline style) - Add ImportBar with Upload CSV button, ? help icon, and disabled Google Sheets button - Fetch events, time blocks, and categories in parallel on mount via Promise.all - Render loading state and error banner; tab panels are placeholders pending subsequent steps - Export fmtTime, fmtDate, fmtDateShort, catColorIndex helpers for use in child components --- .../dashboard/[tournamentId]/events/page.tsx | 402 +++++++++++++++++- 1 file changed, 395 insertions(+), 7 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 494b3a42..a36c19bc 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -1,12 +1,400 @@ -// events/page.tsx "use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useParams } from "next/navigation"; +import { + eventsApi, + timeBlocksApi, + categoriesApi, + Event, + TimeBlock, + TournamentCategory, +} from "@/lib/api"; +import { PageHeader } from "@/components/ui/PageHeader"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +export function fmtTime(hhmm: string): string { + const [hStr, mStr] = hhmm.split(":"); + const h = parseInt(hStr, 10); + const m = parseInt(mStr, 10); + const period = h >= 12 ? "PM" : "AM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return m === 0 ? `${h12} ${period}` : `${h12}:${mStr} ${period}`; +} + +export function fmtDate(yyyymmdd: string): string { + const [, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +export function fmtDateShort(yyyymmdd: string): string { + const [, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +/** Returns the CSS var index (1–5) for a category by its position in the list. */ +export function catColorIndex(idx: number): number { + return (idx % 5) + 1; +} + +// ─── Tab type ───────────────────────────────────────────────────────────────── + +type Tab = "timeline" | "cards" | "blocks"; + +// ─── Tab bar ────────────────────────────────────────────────────────────────── + +function TabBar({ + active, + onChange, +}: { + active: Tab; + onChange: (t: Tab) => void; +}) { + const tabs: { id: Tab; label: string }[] = [ + { id: "timeline", label: "Timeline" }, + { id: "cards", label: "Cards" }, + { id: "blocks", label: "Time Blocks" }, + ]; + + return ( +
+ {tabs.map((t) => { + const isActive = t.id === active; + return ( + + ); + })} +
+ ); +} + +// ─── Import bar ─────────────────────────────────────────────────────────────── + +function ImportBar() { + return ( +
+ + Import + + + {/* CSV upload */} + + + {/* Help icon */} + + +
+ + {/* Google Sheets — disabled until Sheets feature lands */} + +
+ ); +} + +// ─── Placeholder panels (replaced in subsequent steps) ──────────────────────── + +function TimelinePlaceholder() { + return ( +
+ Timeline view — coming soon +
+ ); +} + +function CardsPlaceholder() { + return ( +
+ Cards view — coming soon +
+ ); +} + +// ─── Inline icons ───────────────────────────────────────────────────────────── + +function UploadIcon() { + return ( + + + + + ); +} + +function SheetsIcon() { + return ( + + + + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function EventsPage() { + const params = useParams(); + const tournamentId = Number(params.tournamentId); + + const [activeTab, setActiveTab] = useState("timeline"); + + // Data + const [events, setEvents] = useState([]); + const [timeBlocks, setTimeBlocks] = useState([]); + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const loadAll = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [evts, blocks, cats] = await Promise.all([ + eventsApi.listByTournament(tournamentId), + timeBlocksApi.listByTournament(tournamentId), + categoriesApi.listByTournament(tournamentId), + ]); + setEvents(evts); + setTimeBlocks(blocks); + setCategories(cats); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load events"); + } finally { + setLoading(false); + } + }, [tournamentId]); + + useEffect(() => { loadAll(); }, [loadAll]); + + // ── Render ──────────────────────────────────────────────────────────────── + return ( -
-

Events

-

- Events table — coming soon. -

+
+ + + + + + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading… +
+ ) : ( + <> + {activeTab === "timeline" && ( + + )} + + {activeTab === "cards" && ( + + )} + + {activeTab === "blocks" && ( + // TimeBlocksTable wired in step 4 +
+ Time Blocks table — coming soon +
+ )} + + )}
); -} \ No newline at end of file +} From 7d20127260d339692085bda87624a61123dbaabf Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 16:55:31 -0700 Subject: [PATCH 017/100] refactor: extract time/date/color helpers into lib/formatters.ts - Move fmtTime, fmtDate, fmtDateShort, catColorIndex out of events/page.tsx - Add catColorVars helper that returns all three CSS var names for a category slot - Child components import directly from @/lib/formatters instead of re-exporting through the page --- .../dashboard/[tournamentId]/events/page.tsx | 33 ------------ frontend/lib/formatters.ts | 51 +++++++++++++++++++ 2 files changed, 51 insertions(+), 33 deletions(-) create mode 100644 frontend/lib/formatters.ts diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index a36c19bc..3aa63140 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -12,39 +12,6 @@ import { } from "@/lib/api"; import { PageHeader } from "@/components/ui/PageHeader"; -// ─── Helpers ────────────────────────────────────────────────────────────────── - -export function fmtTime(hhmm: string): string { - const [hStr, mStr] = hhmm.split(":"); - const h = parseInt(hStr, 10); - const m = parseInt(mStr, 10); - const period = h >= 12 ? "PM" : "AM"; - const h12 = h % 12 === 0 ? 12 : h % 12; - return m === 0 ? `${h12} ${period}` : `${h12}:${mStr} ${period}`; -} - -export function fmtDate(yyyymmdd: string): string { - const [, mo, d] = yyyymmdd.split("-").map(Number); - return new Date(0, mo - 1, d).toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - }); -} - -export function fmtDateShort(yyyymmdd: string): string { - const [, mo, d] = yyyymmdd.split("-").map(Number); - return new Date(0, mo - 1, d).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); -} - -/** Returns the CSS var index (1–5) for a category by its position in the list. */ -export function catColorIndex(idx: number): number { - return (idx % 5) + 1; -} - // ─── Tab type ───────────────────────────────────────────────────────────────── type Tab = "timeline" | "cards" | "blocks"; diff --git a/frontend/lib/formatters.ts b/frontend/lib/formatters.ts new file mode 100644 index 00000000..a460306d --- /dev/null +++ b/frontend/lib/formatters.ts @@ -0,0 +1,51 @@ +// ─── Time / date formatters ─────────────────────────────────────────────────── + +/** "HH:MM" (24hr) → "9 AM" / "12:30 PM" */ +export function fmtTime(hhmm: string): string { + const [hStr, mStr] = hhmm.split(":"); + const h = parseInt(hStr, 10); + const m = parseInt(mStr, 10); + const period = h >= 12 ? "PM" : "AM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return m === 0 ? `${h12} ${period}` : `${h12}:${mStr} ${period}`; +} + +/** "YYYY-MM-DD" → "Wed, Mar 15" */ +export function fmtDate(yyyymmdd: string): string { + const [, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +/** "YYYY-MM-DD" → "Mar 15" */ +export function fmtDateShort(yyyymmdd: string): string { + const [, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +// ─── Category color helpers ─────────────────────────────────────────────────── + +/** Returns the CSS var slot (1–5) for a category given its 0-based list index. */ +export function catColorIndex(idx: number): number { + return (idx % 5) + 1; +} + +/** Returns the three CSS variable names for a category color slot. */ +export function catColorVars(idx: number): { + main: string; + subtle: string; + text: string; +} { + const slot = catColorIndex(idx); + return { + main: `var(--color-cat-${slot})`, + subtle: `var(--color-cat-${slot}-subtle)`, + text: `var(--color-cat-${slot}-text)`, + }; +} From 32f0e046c21e813271e19bd2fdd2a26174c619f7 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:01:31 -0700 Subject: [PATCH 018/100] fix: make time_blocks migration idempotent - guard all create_table calls with insp.has_table() so re-running on a DB that already has the tables skips creation - guard add_column and drop_column calls with _has_column() to handle partial migration state - fixes failure on dev DB where time_blocks already existed but preview/prod did not have the tables --- ...ime_blocks_categories_membership_events.py | 105 +++++++++++------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py index 34ee7c0e..af1d9e58 100644 --- a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -26,56 +26,66 @@ depends_on = None +def _has_column(conn, table: str, column: str) -> bool: + insp = sa.inspect(conn) + return any(c['name'] == column for c in insp.get_columns(table)) + + def upgrade(): conn = op.get_bind() + insp = sa.inspect(conn) # ------------------------------------------------------------------ # 1. Create time_blocks # ------------------------------------------------------------------ - op.create_table( - 'time_blocks', - sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), - sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), - sa.Column('label', sa.String(255), nullable=False), - sa.Column('date', sa.String(10), nullable=False), - sa.Column('start', sa.String(5), nullable=False), - sa.Column('end', sa.String(5), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - ) - op.create_index('ix_time_blocks_id', 'time_blocks', ['id']) + if not insp.has_table('time_blocks'): + op.create_table( + 'time_blocks', + sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), + sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), + sa.Column('label', sa.String(255), nullable=False), + sa.Column('date', sa.String(10), nullable=False), + sa.Column('start', sa.String(5), nullable=False), + sa.Column('end', sa.String(5), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + ) + op.create_index('ix_time_blocks_id', 'time_blocks', ['id']) # ------------------------------------------------------------------ # 2. Create tournament_categories # ------------------------------------------------------------------ - op.create_table( - 'tournament_categories', - sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), - sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), - sa.Column('name', sa.String(255), nullable=False), - sa.Column('is_custom', sa.Boolean(), nullable=False, server_default=sa.false()), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.UniqueConstraint('tournament_id', 'name', name='uq_tournament_category_name'), - ) - op.create_index('ix_tournament_categories_id', 'tournament_categories', ['id']) + if not insp.has_table('tournament_categories'): + op.create_table( + 'tournament_categories', + sa.Column('id', sa.Integer(), primary_key=True, index=True, nullable=False), + sa.Column('tournament_id', sa.Integer(), sa.ForeignKey('tournaments.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(255), nullable=False), + sa.Column('is_custom', sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.UniqueConstraint('tournament_id', 'name', name='uq_tournament_category_name'), + ) + op.create_index('ix_tournament_categories_id', 'tournament_categories', ['id']) # ------------------------------------------------------------------ # 3. Create event_time_blocks association table # ------------------------------------------------------------------ - op.create_table( - 'event_time_blocks', - sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), - sa.Column('time_block_id', sa.Integer(), sa.ForeignKey('time_blocks.id', ondelete='CASCADE'), primary_key=True, nullable=False), - ) + if not insp.has_table('event_time_blocks'): + op.create_table( + 'event_time_blocks', + sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), + sa.Column('time_block_id', sa.Integer(), sa.ForeignKey('time_blocks.id', ondelete='CASCADE'), primary_key=True, nullable=False), + ) # ------------------------------------------------------------------ # 4. Create membership_events association table # ------------------------------------------------------------------ - op.create_table( - 'membership_events', - sa.Column('membership_id', sa.Integer(), sa.ForeignKey('memberships.id', ondelete='CASCADE'), primary_key=True, nullable=False), - sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), - ) + if not insp.has_table('membership_events'): + op.create_table( + 'membership_events', + sa.Column('membership_id', sa.Integer(), sa.ForeignKey('memberships.id', ondelete='CASCADE'), primary_key=True, nullable=False), + sa.Column('event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='CASCADE'), primary_key=True, nullable=False), + ) # ------------------------------------------------------------------ # 5. Backfill time_blocks from tournaments.blocks JSON @@ -133,7 +143,8 @@ def upgrade(): # ------------------------------------------------------------------ # 7. Add events.category_id and backfill from events.category string # ------------------------------------------------------------------ - op.add_column('events', sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) + if not _has_column(conn, 'events', 'category_id'): + op.add_column('events', sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) # For each distinct (tournament_id, category) string pair, find-or-create a TournamentCategory row rows = conn.execute(text( @@ -210,14 +221,19 @@ def upgrade(): # ------------------------------------------------------------------ # 10. Drop legacy columns # ------------------------------------------------------------------ - op.drop_column('tournaments', 'blocks') - op.drop_column('events', 'blocks') - op.drop_column('events', 'category') - op.drop_column('memberships', 'assigned_event_id') + if _has_column(conn, 'tournaments', 'blocks'): + op.drop_column('tournaments', 'blocks') + if _has_column(conn, 'events', 'blocks'): + op.drop_column('events', 'blocks') + if _has_column(conn, 'events', 'category'): + op.drop_column('events', 'category') + if _has_column(conn, 'memberships', 'assigned_event_id'): + op.drop_column('memberships', 'assigned_event_id') def downgrade(): conn = op.get_bind() + insp = sa.inspect(conn) # Re-add legacy columns op.add_column('memberships', sa.Column('assigned_event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='SET NULL'), nullable=True)) @@ -234,10 +250,15 @@ def downgrade(): WHERE e.category_id IS NOT NULL """)) - op.drop_column('events', 'category_id') + if _has_column(conn, 'events', 'category_id'): + op.drop_column('events', 'category_id') # Drop new tables (cascade handles association tables) - op.drop_table('membership_events') - op.drop_table('event_time_blocks') - op.drop_table('tournament_categories') - op.drop_table('time_blocks') + if insp.has_table('membership_events'): + op.drop_table('membership_events') + if insp.has_table('event_time_blocks'): + op.drop_table('event_time_blocks') + if insp.has_table('tournament_categories'): + op.drop_table('tournament_categories') + if insp.has_table('time_blocks'): + op.drop_table('time_blocks') From 87cf4f04daa86787246a75b4a0911bc97a012798 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:03:33 -0700 Subject: [PATCH 019/100] fix: handle raw JSON strings in migration backfill - add json.loads() with isinstance str guard for tournaments.blocks, events.blocks, and memberships.schedule backfill steps - SQLite returns raw JSON strings via text() queries unlike Postgres which auto-deserializes - hoist import json to top of upgrade() so it's available across all backfill steps --- ...3d0c5a5ffe_time_blocks_categories_membership_events.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py index af1d9e58..d91870c2 100644 --- a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -93,6 +93,7 @@ def upgrade(): # [{"number": 1, "label": "Block 1", "date": "YYYY-MM-DD", "start": "HH:MM", "end": "HH:MM"}, ...] # We build a mapping: (tournament_id, old_number) -> new time_block id # ------------------------------------------------------------------ + import json from datetime import datetime, timezone now = datetime.now(timezone.utc).replace(tzinfo=None) @@ -104,6 +105,8 @@ def upgrade(): for t_id, blocks_json in tournaments: if not blocks_json: continue + if isinstance(blocks_json, str): + blocks_json = json.loads(blocks_json) old_number_to_id[t_id] = {} for block in blocks_json: number = block.get("number") @@ -130,6 +133,8 @@ def upgrade(): for e_id, t_id, blocks_json in events: if not blocks_json: continue + if isinstance(blocks_json, str): + blocks_json = json.loads(blocks_json) t_map = old_number_to_id.get(t_id, {}) for block_num in blocks_json: tb_id = t_map.get(block_num) @@ -195,13 +200,14 @@ def upgrade(): # 9. Migrate memberships.schedule JSON: # [{block: int, duty: str}] -> [{time_block_id: int, duty: str}] # ------------------------------------------------------------------ - import json memberships_with_schedule = conn.execute( text("SELECT id, tournament_id, schedule FROM memberships WHERE schedule IS NOT NULL") ).fetchall() for m_id, t_id, schedule_json in memberships_with_schedule: if not schedule_json: continue + if isinstance(schedule_json, str): + schedule_json = json.loads(schedule_json) t_map = old_number_to_id.get(t_id, {}) new_schedule = [] changed = False From 229516b4ab54ca0a2adde0e0b17dda1765a532dc Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:05:52 -0700 Subject: [PATCH 020/100] fix: use batch_alter_table for SQLite-compatible column changes - wrap all add_column and drop_column calls in op.batch_alter_table() contexts - SQLite does not support ALTER TABLE for FK constraints or column drops; batch mode uses copy-and-move - fix UPDATE alias in downgrade (SQLite does not support table aliases in UPDATE statements) - consolidate events batch blocks where multiple columns are dropped together --- ...ime_blocks_categories_membership_events.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py index d91870c2..f7df896b 100644 --- a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -149,7 +149,8 @@ def upgrade(): # 7. Add events.category_id and backfill from events.category string # ------------------------------------------------------------------ if not _has_column(conn, 'events', 'category_id'): - op.add_column('events', sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) + with op.batch_alter_table('events') as batch_op: + batch_op.add_column(sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) # For each distinct (tournament_id, category) string pair, find-or-create a TournamentCategory row rows = conn.execute(text( @@ -225,39 +226,47 @@ def upgrade(): ) # ------------------------------------------------------------------ - # 10. Drop legacy columns + # 10. Drop legacy columns (batch mode required for SQLite) # ------------------------------------------------------------------ if _has_column(conn, 'tournaments', 'blocks'): - op.drop_column('tournaments', 'blocks') - if _has_column(conn, 'events', 'blocks'): - op.drop_column('events', 'blocks') - if _has_column(conn, 'events', 'category'): - op.drop_column('events', 'category') + with op.batch_alter_table('tournaments') as batch_op: + batch_op.drop_column('blocks') + if _has_column(conn, 'events', 'blocks') or _has_column(conn, 'events', 'category'): + with op.batch_alter_table('events') as batch_op: + if _has_column(conn, 'events', 'blocks'): + batch_op.drop_column('blocks') + if _has_column(conn, 'events', 'category'): + batch_op.drop_column('category') if _has_column(conn, 'memberships', 'assigned_event_id'): - op.drop_column('memberships', 'assigned_event_id') + with op.batch_alter_table('memberships') as batch_op: + batch_op.drop_column('assigned_event_id') def downgrade(): conn = op.get_bind() insp = sa.inspect(conn) - # Re-add legacy columns - op.add_column('memberships', sa.Column('assigned_event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='SET NULL'), nullable=True)) - op.add_column('events', sa.Column('category', sa.String(255), nullable=True)) - op.add_column('events', sa.Column('blocks', sa.JSON(), nullable=True)) - op.add_column('tournaments', sa.Column('blocks', sa.JSON(), nullable=True)) + # Re-add legacy columns (batch mode required for SQLite) + with op.batch_alter_table('memberships') as batch_op: + batch_op.add_column(sa.Column('assigned_event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='SET NULL'), nullable=True)) + with op.batch_alter_table('events') as batch_op: + batch_op.add_column(sa.Column('category', sa.String(255), nullable=True)) + batch_op.add_column(sa.Column('blocks', sa.JSON(), nullable=True)) + with op.batch_alter_table('tournaments') as batch_op: + batch_op.add_column(sa.Column('blocks', sa.JSON(), nullable=True)) # Restore events.category from category_id conn.execute(text(""" - UPDATE events e + UPDATE events SET category = ( - SELECT tc.name FROM tournament_categories tc WHERE tc.id = e.category_id + SELECT tc.name FROM tournament_categories tc WHERE tc.id = events.category_id ) - WHERE e.category_id IS NOT NULL + WHERE events.category_id IS NOT NULL """)) if _has_column(conn, 'events', 'category_id'): - op.drop_column('events', 'category_id') + with op.batch_alter_table('events') as batch_op: + batch_op.drop_column('category_id') # Drop new tables (cascade handles association tables) if insp.has_table('membership_events'): From efe80e56dac6060e99408f158c9fdbc075f0a9fb Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:07:42 -0700 Subject: [PATCH 021/100] fix: remove inline ForeignKey from batch_alter_table add_column calls - SQLite batch mode requires FK constraints to have explicit names; inline ForeignKey raises ValueError - drop FK from category_id and assigned_event_id add_column calls in upgrade and downgrade - SQLite does not enforce FK constraints by default so omitting them from DDL is safe --- .../133d0c5a5ffe_time_blocks_categories_membership_events.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py index f7df896b..1bf37f2b 100644 --- a/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -150,7 +150,7 @@ def upgrade(): # ------------------------------------------------------------------ if not _has_column(conn, 'events', 'category_id'): with op.batch_alter_table('events') as batch_op: - batch_op.add_column(sa.Column('category_id', sa.Integer(), sa.ForeignKey('tournament_categories.id', ondelete='SET NULL'), nullable=True)) + batch_op.add_column(sa.Column('category_id', sa.Integer(), nullable=True)) # For each distinct (tournament_id, category) string pair, find-or-create a TournamentCategory row rows = conn.execute(text( @@ -248,7 +248,7 @@ def downgrade(): # Re-add legacy columns (batch mode required for SQLite) with op.batch_alter_table('memberships') as batch_op: - batch_op.add_column(sa.Column('assigned_event_id', sa.Integer(), sa.ForeignKey('events.id', ondelete='SET NULL'), nullable=True)) + batch_op.add_column(sa.Column('assigned_event_id', sa.Integer(), nullable=True)) with op.batch_alter_table('events') as batch_op: batch_op.add_column(sa.Column('category', sa.String(255), nullable=True)) batch_op.add_column(sa.Column('blocks', sa.JSON(), nullable=True)) From e56f5f7643b52c678b160eaddfb719df5fd64206 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:13:05 -0700 Subject: [PATCH 022/100] feat: add TimeBlocksTable component and wire into events page - Create components/events/TimeBlocksTable.tsx with Label, Day, Time range, Events count, Actions columns - Day separator rows group blocks by date using fmtDate from lib/formatters - Events count badge shows how many events are assigned to each block - Empty state with serif heading and contextual message for read-only vs manage mode - Add block button in toolbar (manage mode only); Edit/Delete action buttons per row with hover states - Wire into events page blocks tab; onAdd/onEdit/onDelete stubbed pending steps 5 and 6 --- .../dashboard/[tournamentId]/events/page.tsx | 24 +- .../components/events/TimeBlocksTable.tsx | 378 ++++++++++++++++++ 2 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 frontend/components/events/TimeBlocksTable.tsx diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 3aa63140..f234012d 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -11,6 +11,7 @@ import { TournamentCategory, } from "@/lib/api"; import { PageHeader } from "@/components/ui/PageHeader"; +import { TimeBlocksTable } from "@/components/events/TimeBlocksTable"; // ─── Tab type ───────────────────────────────────────────────────────────────── @@ -343,22 +344,13 @@ export default function EventsPage() { )} {activeTab === "blocks" && ( - // TimeBlocksTable wired in step 4 -
- Time Blocks table — coming soon -
+ {/* TODO: step 5 */}} + onEdit={() => {/* TODO: step 5 */}} + onDelete={() => {/* TODO: step 6 */}} + /> )} )} diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx new file mode 100644 index 00000000..ac3bf03e --- /dev/null +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { TimeBlock, Event } from "@/lib/api"; +import { fmtTime, fmtDate } from "@/lib/formatters"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + timeBlocks: TimeBlock[]; + events: Event[]; + isReadOnly?: boolean; + onAdd: () => void; + onEdit: (block: TimeBlock) => void; + onDelete: (block: TimeBlock) => void; +} + +// ─── Styles ─────────────────────────────────────────────────────────────────── + +const thStyle: React.CSSProperties = { + fontFamily: "var(--font-sans)", + fontSize: "11px", + fontWeight: 600, + color: "var(--color-text-secondary)", + textTransform: "uppercase", + letterSpacing: "0.04em", + padding: "9px 14px", + textAlign: "left", + borderBottom: "1px solid var(--color-border)", + whiteSpace: "nowrap", + background: "var(--color-surface)", +}; + +const tdStyle: React.CSSProperties = { + fontFamily: "var(--font-mono)", + fontSize: "13px", + color: "var(--color-text-primary)", + padding: "10px 14px", + borderBottom: "1px solid var(--color-border)", + verticalAlign: "middle", +}; + +// ─── Day separator row ──────────────────────────────────────────────────────── + +function DaySeparator({ date }: { date: string }) { + return ( + + + {fmtDate(date)} + + + ); +} + +// ─── Action button ──────────────────────────────────────────────────────────── + +function ActionBtn({ + children, + onClick, + danger, + title, +}: { + children: React.ReactNode; + onClick: () => void; + danger?: boolean; + title?: string; +}) { + return ( + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function TimeBlocksTable({ + timeBlocks, + events, + isReadOnly = false, + onAdd, + onEdit, + onDelete, +}: Props) { + // Count how many events are assigned to each block + const eventCount = (blockId: number) => + events.filter((e) => e.time_block_ids.includes(blockId)).length; + + // Group blocks by date (already ordered by date then start from API) + const rows: Array<{ type: "separator"; date: string } | { type: "block"; block: TimeBlock }> = []; + let lastDate = ""; + for (const block of timeBlocks) { + if (block.date !== lastDate) { + rows.push({ type: "separator", date: block.date }); + lastDate = block.date; + } + rows.push({ type: "block", block }); + } + + return ( +
+ {/* ── Toolbar ── */} +
+ + {timeBlocks.length} block{timeBlocks.length !== 1 ? "s" : ""} + + + {!isReadOnly && ( + + )} +
+ + {/* ── Empty state ── */} + {timeBlocks.length === 0 ? ( +
+

+ No time blocks yet +

+

+ {isReadOnly + ? "No blocks have been scheduled for this tournament." + : "Add a block to start scheduling events."} +

+
+ ) : ( + /* ── Table ── */ +
+ + + + + + + + {!isReadOnly && ( + + )} + + + + {rows.map((row, i) => { + if (row.type === "separator") { + return ; + } + + const { block } = row; + const count = eventCount(block.id); + const isLast = i === rows.length - 1; + + return ( + { + (e.currentTarget as HTMLTableRowElement).style.background = + "var(--color-bg)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = + "transparent"; + }} + > + {/* Label */} + + + {/* Day */} + + + {/* Time range */} + + + {/* Events count */} + + + {/* Actions */} + {!isReadOnly && ( + + )} + + ); + })} + +
LabelDayTime rangeEventsActions
+ {block.label} + + {fmtDate(block.date)} + + {fmtTime(block.start)} + + {fmtTime(block.end)} + + {count > 0 ? ( + + {count} + + ) : ( + + — + + )} + + onEdit(block)} title="Edit block"> + Edit + + onDelete(block)} + danger + title="Delete block" + > + Delete + +
+
+ )} +
+ ); +} + +// ─── Icon ───────────────────────────────────────────────────────────────────── + +function PlusIcon() { + return ( + + + + ); +} From b789a35b01f51db8bdaf2b044f804f9f36fc1069 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:19:34 -0700 Subject: [PATCH 023/100] feat: add TimeBlocksTable component and wire into events page - Create components/events/TimeBlocksTable.tsx with Label, Day, Time range, Events count, Actions columns - Day separator rows group blocks by date using fmtDate from lib/formatters - Events count badge shows how many events are assigned to each block - Empty state with serif heading and contextual message for read-only vs manage mode - Add block button uses Button component with IconPlus; edit/delete use Button ghost + IconEdit/IconTrash - Fix fmtTime in lib/formatters to always show minutes (8:00 AM not 8 AM) - Wire into events page blocks tab; onAdd/onEdit/onDelete stubbed pending steps 5 and 6 --- .../components/events/TimeBlocksTable.tsx | 120 +++--------------- frontend/lib/formatters.ts | 5 +- 2 files changed, 22 insertions(+), 103 deletions(-) diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx index ac3bf03e..155b513d 100644 --- a/frontend/components/events/TimeBlocksTable.tsx +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -2,6 +2,8 @@ import { TimeBlock, Event } from "@/lib/api"; import { fmtTime, fmtDate } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; +import { IconPlus, IconEdit, IconTrash } from "@/components/ui/Icons"; // ─── Props ──────────────────────────────────────────────────────────────────── @@ -65,54 +67,6 @@ function DaySeparator({ date }: { date: string }) { ); } -// ─── Action button ──────────────────────────────────────────────────────────── - -function ActionBtn({ - children, - onClick, - danger, - title, -}: { - children: React.ReactNode; - onClick: () => void; - danger?: boolean; - title?: string; -}) { - return ( - - ); -} // ─── Component ──────────────────────────────────────────────────────────────── @@ -161,35 +115,10 @@ export function TimeBlocksTable({ {!isReadOnly && ( - + )}
@@ -336,16 +265,24 @@ export function TimeBlocksTable({ whiteSpace: "nowrap", }} > - onEdit(block)} title="Edit block"> - Edit - - onEdit(block)} + title="Edit block" + style={{ padding: "0 8px" }} + > + + + )} @@ -359,20 +296,3 @@ export function TimeBlocksTable({ ); } -// ─── Icon ───────────────────────────────────────────────────────────────────── - -function PlusIcon() { - return ( - - - - ); -} diff --git a/frontend/lib/formatters.ts b/frontend/lib/formatters.ts index a460306d..54ee84fc 100644 --- a/frontend/lib/formatters.ts +++ b/frontend/lib/formatters.ts @@ -1,13 +1,12 @@ // ─── Time / date formatters ─────────────────────────────────────────────────── -/** "HH:MM" (24hr) → "9 AM" / "12:30 PM" */ +/** "HH:MM" (24hr) → "9:00 AM" / "12:30 PM" */ export function fmtTime(hhmm: string): string { const [hStr, mStr] = hhmm.split(":"); const h = parseInt(hStr, 10); - const m = parseInt(mStr, 10); const period = h >= 12 ? "PM" : "AM"; const h12 = h % 12 === 0 ? 12 : h % 12; - return m === 0 ? `${h12} ${period}` : `${h12}:${mStr} ${period}`; + return `${h12}:${mStr} ${period}`; } /** "YYYY-MM-DD" → "Wed, Mar 15" */ From bd88ced5c93994d92f59980856bf2826fb02b7d5 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 17:25:12 -0700 Subject: [PATCH 024/100] feat: add inline add/edit row to TimeBlocksTable - Add InlineRow component inside TimeBlocksTable with label, date, start, end inputs - Manages editingId and showAddRow state internally; only one row editable at a time - Edit row pre-fills from existing block values; add row starts blank with label focused - Enter key submits, Escape cancels; Save disabled until all four fields filled - Error from API displayed in a row beneath the inline form - Add/edit buttons disabled while any row is open - Table shown even when blocks list is empty if add row is open - Wire handleAddBlock and handleEditBlock in page.tsx calling timeBlocksApi.create/update then reloading --- .../dashboard/[tournamentId]/events/page.tsx | 17 +- .../components/events/TimeBlocksTable.tsx | 372 ++++++++++++++---- 2 files changed, 308 insertions(+), 81 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index f234012d..53d77f03 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -8,6 +8,7 @@ import { categoriesApi, Event, TimeBlock, + TimeBlockCreate, TournamentCategory, } from "@/lib/api"; import { PageHeader } from "@/components/ui/PageHeader"; @@ -289,6 +290,18 @@ export default function EventsPage() { useEffect(() => { loadAll(); }, [loadAll]); + // ── Time block handlers ─────────────────────────────────────────────────── + + const handleAddBlock = async (data: TimeBlockCreate) => { + await timeBlocksApi.create(tournamentId, data); + await loadAll(); + }; + + const handleEditBlock = async (id: number, data: Partial) => { + await timeBlocksApi.update(tournamentId, id, data); + await loadAll(); + }; + // ── Render ──────────────────────────────────────────────────────────────── return ( @@ -347,8 +360,8 @@ export default function EventsPage() { {/* TODO: step 5 */}} - onEdit={() => {/* TODO: step 5 */}} + onAdd={handleAddBlock} + onEdit={handleEditBlock} onDelete={() => {/* TODO: step 6 */}} /> )} diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx index 155b513d..59c82782 100644 --- a/frontend/components/events/TimeBlocksTable.tsx +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -1,6 +1,7 @@ "use client"; -import { TimeBlock, Event } from "@/lib/api"; +import { useState, useEffect, useRef } from "react"; +import { TimeBlock, TimeBlockCreate, Event } from "@/lib/api"; import { fmtTime, fmtDate } from "@/lib/formatters"; import { Button } from "@/components/ui/Button"; import { IconPlus, IconEdit, IconTrash } from "@/components/ui/Icons"; @@ -11,8 +12,8 @@ interface Props { timeBlocks: TimeBlock[]; events: Event[]; isReadOnly?: boolean; - onAdd: () => void; - onEdit: (block: TimeBlock) => void; + onAdd: (data: TimeBlockCreate) => Promise; + onEdit: (id: number, data: Partial) => Promise; onDelete: (block: TimeBlock) => void; } @@ -33,32 +34,48 @@ const thStyle: React.CSSProperties = { }; const tdStyle: React.CSSProperties = { - fontFamily: "var(--font-mono)", - fontSize: "13px", - color: "var(--color-text-primary)", - padding: "10px 14px", - borderBottom: "1px solid var(--color-border)", + fontFamily: "var(--font-mono)", + fontSize: "13px", + color: "var(--color-text-primary)", + padding: "10px 14px", + borderBottom: "1px solid var(--color-border)", verticalAlign: "middle", }; +const inputStyle: React.CSSProperties = { + fontFamily: "var(--font-mono)", + fontSize: "13px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border-strong)", + borderRadius: "var(--radius-sm)", + padding: "4px 8px", + outline: "none", + width: "100%", +}; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const EMPTY_FORM: TimeBlockCreate = { label: "", date: "", start: "", end: "" }; + // ─── Day separator row ──────────────────────────────────────────────────────── -function DaySeparator({ date }: { date: string }) { +function DaySeparator({ date, colSpan }: { date: string; colSpan: number }) { return ( {fmtDate(date)} @@ -67,6 +84,133 @@ function DaySeparator({ date }: { date: string }) { ); } +// ─── Inline form row ────────────────────────────────────────────────────────── + +function InlineRow({ + initial, + colSpan, + saving, + error, + onSave, + onCancel, +}: { + initial: TimeBlockCreate; + colSpan: number; + saving: boolean; + error: string | null; + onSave: (data: TimeBlockCreate) => void; + onCancel: () => void; +}) { + const [form, setForm] = useState(initial); + const labelRef = useRef(null); + + // Focus label on mount + useEffect(() => { labelRef.current?.focus(); }, []); + + const set = (field: keyof TimeBlockCreate, value: string) => + setForm((f) => ({ ...f, [field]: value })); + + const valid = form.label.trim() && form.date && form.start && form.end; + + const handleKey = (e: React.KeyboardEvent) => { + if (e.key === "Escape") onCancel(); + if (e.key === "Enter" && valid) onSave(form); + }; + + return ( + <> + + {/* Label */} + + set("label", e.target.value)} + style={{ ...inputStyle, minWidth: "120px" }} + /> + + + {/* Date */} + + set("date", e.target.value)} + style={{ ...inputStyle, minWidth: "140px" }} + /> + + + {/* Time range — start + end side by side */} + +
+ set("start", e.target.value)} + style={{ ...inputStyle, width: "110px" }} + /> + + set("end", e.target.value)} + style={{ ...inputStyle, width: "110px" }} + /> +
+ + + {/* Events count — empty for new/edit row */} + + + + + {/* Actions */} + {colSpan === 5 && ( + + + + + )} + + + {/* Validation / API error */} + {error && ( + + + {error} + + + )} + + ); +} // ─── Component ──────────────────────────────────────────────────────────────── @@ -78,12 +222,67 @@ export function TimeBlocksTable({ onEdit, onDelete, }: Props) { + const [editingId, setEditingId] = useState(null); + const [showAddRow, setShowAddRow] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + // Number of table columns (5 in manage mode, 4 in read-only) + const colSpan = isReadOnly ? 4 : 5; + + const handleAddClick = () => { + setEditingId(null); + setSaveError(null); + setShowAddRow(true); + }; + + const handleEditClick = (block: TimeBlock) => { + setShowAddRow(false); + setSaveError(null); + setEditingId(block.id); + }; + + const handleCancel = () => { + setEditingId(null); + setShowAddRow(false); + setSaveError(null); + }; + + const handleSaveNew = async (data: TimeBlockCreate) => { + setSaving(true); + setSaveError(null); + try { + await onAdd(data); + setShowAddRow(false); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Failed to save block"); + } finally { + setSaving(false); + } + }; + + const handleSaveEdit = async (id: number, data: TimeBlockCreate) => { + setSaving(true); + setSaveError(null); + try { + await onEdit(id, data); + setEditingId(null); + } catch (e) { + setSaveError(e instanceof Error ? e.message : "Failed to save block"); + } finally { + setSaving(false); + } + }; + // Count how many events are assigned to each block const eventCount = (blockId: number) => events.filter((e) => e.time_block_ids.includes(blockId)).length; - // Group blocks by date (already ordered by date then start from API) - const rows: Array<{ type: "separator"; date: string } | { type: "block"; block: TimeBlock }> = []; + // Group blocks by date (pre-sorted by API: date then start) + const rows: Array< + | { type: "separator"; date: string } + | { type: "block"; block: TimeBlock } + > = []; let lastDate = ""; for (const block of timeBlocks) { if (block.date !== lastDate) { @@ -93,6 +292,10 @@ export function TimeBlocksTable({ rows.push({ type: "block", block }); } + // Decide whether to show the table wrapper (show it even when empty if + // the add row is open, so the inline row has a home) + const showTable = timeBlocks.length > 0 || showAddRow; + return (
{/* ── Toolbar ── */} @@ -115,15 +318,19 @@ export function TimeBlocksTable({ {!isReadOnly && ( - )}
- {/* ── Empty state ── */} - {timeBlocks.length === 0 ? ( + {/* ── Empty state (no blocks, no add row open) ── */} + {!showTable && (
-

+

No time blocks yet

-

+

{isReadOnly ? "No blocks have been scheduled for this tournament." : "Add a block to start scheduling events."}

- ) : ( - /* ── Table ── */ + )} + + {/* ── Table ── */} + {showTable && (
{rows.map((row, i) => { if (row.type === "separator") { - return ; + return ( + + ); } const { block } = row; - const count = eventCount(block.id); - const isLast = i === rows.length - 1; + const count = eventCount(block.id); + const isLast = i === rows.length - 1 && !showAddRow; + const editing = editingId === block.id; + + if (editing) { + return ( + handleSaveEdit(block.id, data)} + onCancel={handleCancel} + /> + ); + } return ( { - (e.currentTarget as HTMLTableRowElement).style.background = - "var(--color-bg)"; + (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-bg)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLTableRowElement).style.background = - "transparent"; + (e.currentTarget as HTMLTableRowElement).style.background = "transparent"; }} > {/* Label */} @@ -222,55 +438,41 @@ export function TimeBlocksTable({ {/* Events count */} - + {count > 0 ? ( {count} ) : ( - - — - + )} {/* Actions */} {!isReadOnly && ( - + @@ -280,6 +482,7 @@ export function TimeBlocksTable({ onClick={() => onDelete(block)} title="Delete block" style={{ padding: "0 8px", color: "var(--color-danger)" }} + disabled={showAddRow || editingId !== null} > @@ -288,6 +491,18 @@ export function TimeBlocksTable({ ); })} + + {/* ── Add row at bottom ── */} + {showAddRow && ( + + )}
@@ -295,4 +510,3 @@ export function TimeBlocksTable({
); } - From cd4a97c1664f4f52ece628f0823ffa42dfd73166 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 18:52:21 -0700 Subject: [PATCH 025/100] fix: stabilize TimeBlocksTable column widths and inline row appearance - Switch table to table-layout: fixed with explicit column widths (Label 20%, Day 16%, Time range 26%, Events 20%, Actions 18%) - Column widths no longer reflow when inline add/edit row appears - Remove yellow warning background and left border accent from inline edit row - Restore bottom border on all inline row cells so row separator stays visible - Time inputs use flex: 1 / minWidth: 0 to fill the fixed column without overflowing --- .../components/events/TimeBlocksTable.tsx | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx index 59c82782..6ac8d983 100644 --- a/frontend/components/events/TimeBlocksTable.tsx +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -119,15 +119,9 @@ function InlineRow({ return ( <> - + {/* Label */} - + {/* Date */} - + {/* Time range — start + end side by side */} - +
set("start", e.target.value)} - style={{ ...inputStyle, width: "110px" }} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} /> set("end", e.target.value)} - style={{ ...inputStyle, width: "110px" }} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} />
{/* Events count — empty for new/edit row */} - + {/* Actions */} {colSpan === 5 && ( - + - - {/* View toggle */} -
- {(["cards", "table"] as ViewMode[]).map((mode) => { - const active = viewMode === mode; - return ( - - ); - })} -
{/* ── Category chips ── */} @@ -231,17 +179,8 @@ export function EventCardGrid({ )} - {/* ── Content: cards or table ── */} - {viewMode === "table" ? ( - - ) : filtered.length === 0 ? ( + {/* ── Cards grid ── */} + {filtered.length === 0 ? (
Date: Wed, 15 Apr 2026 23:43:05 -0700 Subject: [PATCH 039/100] feat: add EventTable tab with toolbar, fix row background colors - move Table view to its own top-level tab (Timeline / Cards / Table / Time Blocks) - add search, division/type filters, category chips, event count, and Add event button to EventTable - fix row backgrounds: var(--color-surface) (#fff) against var(--color-bg) page background - hover rows shift to var(--color-accent-subtle); sticky header uses var(--color-bg) - pass onAddClick from page to EventTable; guard time_block_ids undefined in EventSidePanel --- .../dashboard/[tournamentId]/events/page.tsx | 1 + frontend/components/events/EventTable.tsx | 287 ++++++++++++++---- 2 files changed, 227 insertions(+), 61 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 562c283b..3901828d 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -394,6 +394,7 @@ export default function EventsPage() { timeBlocks={timeBlocks} onUpdate={handleUpdateEvent} onCreateCategory={handleCreateCategory} + onAddClick={() => setPanel({ type: "add" })} /> )} diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 80919b69..2cd068be 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -1,13 +1,17 @@ "use client"; -import { useState, useRef, useEffect, CSSProperties } from "react"; +import { useState, useRef, useEffect, useMemo, CSSProperties } from "react"; import { Event, EventCreate, TimeBlock, TournamentCategory } from "@/lib/api"; import { catColorVars, fmtTime, fmtDateShort } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; +import { IconPlus, IconSearch } from "@/components/ui/Icons"; // ─── Types ──────────────────────────────────────────────────────────────────── -type TextCol = "name" | "building" | "room" | "floor"; -type NumCol = "volunteers_needed"; +type TextCol = "name" | "building" | "room" | "floor"; +type NumCol = "volunteers_needed"; +type DivFilter = "B" | "C" | null; +type TypeFilter = "standard" | "trial" | null; interface Props { events: Event[]; @@ -15,6 +19,7 @@ interface Props { timeBlocks: TimeBlock[]; onUpdate: (id: number, delta: Partial) => Promise; onCreateCategory: (name: string) => Promise; + onAddClick: () => void; isReadOnly?: boolean; } @@ -599,11 +604,11 @@ function EventTableRow({ return ( { (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-surface)"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLTableRowElement).style.background = "transparent"; }} + onMouseEnter={(e) => { (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-accent-subtle)"; }} + onMouseLeave={(e) => { (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-surface)"; }} > {renderTextCell("name", 200)} @@ -667,8 +672,41 @@ export function EventTable({ timeBlocks, onUpdate, onCreateCategory, + onAddClick, isReadOnly, }: Props) { + const [search, setSearch] = useState(""); + const [division, setDivision] = useState(null); + const [eventType, setEventType] = useState(null); + const [categoryId, setCategoryId] = useState(null); + + const filtered = useMemo(() => { + const q = search.toLowerCase().trim(); + return events.filter((e) => { + if (q && !e.name.toLowerCase().includes(q)) return false; + if (division !== null && e.division !== division) return false; + if (eventType !== null && e.event_type !== eventType) return false; + if (categoryId !== null && e.category_id !== categoryId) return false; + return true; + }); + }, [events, search, division, eventType, categoryId]); + + const filterBtn = (active: boolean): CSSProperties => ({ + fontFamily: "var(--font-sans)", + fontSize: "12px", + fontWeight: active ? 600 : 400, + color: active ? "var(--color-text-inverse)" : "var(--color-text-secondary)", + background: active ? "var(--color-accent)" : "transparent", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + height: "30px", + padding: "0 10px", + cursor: "pointer", + transition: "background var(--transition-fast), color var(--transition-fast)", + whiteSpace: "nowrap", + boxSizing: "border-box", + }); + const cols: { label: string; width: number | "auto" }[] = [ { label: "Name", width: 200 }, { label: "Category", width: 130 }, @@ -691,7 +729,7 @@ export function EventTable({ color: "var(--color-text-secondary)", textTransform: "uppercase", letterSpacing: "0.04em", - background: "var(--color-surface)", + background: "var(--color-bg)", borderBottom: "1px solid var(--color-border)", whiteSpace: "nowrap", userSelect: "none", @@ -700,65 +738,192 @@ export function EventTable({ zIndex: 10, }; - if (events.length === 0) { - return ( -
+ {/* ── Toolbar ── */} +
+ {/* Search */} +
+ + + + setSearch(e.target.value)} + placeholder="Search events…" + style={{ + width: "100%", + height: "30px", + padding: "0 10px 0 28px", + fontFamily: "var(--font-mono)", + fontSize: "12px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + outline: "none", + boxSizing: "border-box", + }} + /> +
+ + {/* Division filter */} +
+ {([null, "B", "C"] as DivFilter[]).map((d) => ( + + ))} +
+ + {/* Type filter */} +
+ {([null, "standard", "trial"] as TypeFilter[]).map((t) => ( + + ))} +
+ + {/* Spacer */} +
+ + {/* Result count */} + + {filtered.length} event{filtered.length !== 1 ? "s" : ""} + + + {/* Add event */} + {!isReadOnly && ( + + )} +
+ + {/* ── Category chips ── */} + {categories.length > 0 && ( +
+ + {categories.map((cat, idx) => { + const active = categoryId === cat.id; + const cv = catColorVars(idx); + return ( + + ); + })} +
+ )} + + {/* ── Table ── */} + {filtered.length === 0 ? ( +
- No events match your filters. -
- ); - } - - return ( -
- - - - {cols.map((col) => ( - - ))} - - - - {events.map((ev) => ( - - ))} - -
- {col.label} -
+ }}> + {events.length === 0 ? ( + <> + No events yet. + {!isReadOnly && ( + + )} + + ) : ( + No events match your filters. + )} +
+ ) : ( +
+ + + + {cols.map((col) => ( + + ))} + + + + {filtered.map((ev) => ( + + ))} + +
+ {col.label} +
+
+ )}
); } From 3a2ff41908ae1addc7d40f362794a8d46151a749 Mon Sep 17 00:00:00 2001 From: Ethan Date: Wed, 15 Apr 2026 23:56:09 -0700 Subject: [PATCH 040/100] fix: sync time_block_ids from API and prevent table remount on updates - normalize time_block_ids from time_blocks at load time so panel chips and table cells match the card display - add silent=true flag to loadAll so update-triggered refreshes don't set loading=true, preventing EventTable from unmounting and losing filter state - all update handlers (save, update, add/edit/delete block, create category) now call loadAll(true) - add useEffect to sync panel.event with fresh event data after each silent refresh --- .../dashboard/[tournamentId]/events/page.tsx | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 3901828d..5ade0bf9 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -240,37 +240,52 @@ export default function EventsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const loadAll = useCallback(async () => { - setLoading(true); + const loadAll = useCallback(async (silent = false) => { + if (!silent) setLoading(true); setError(null); try { - const [evts, blocks, cats] = await Promise.all([ + const [rawEvts, blocks, cats] = await Promise.all([ eventsApi.listByTournament(tournamentId), timeBlocksApi.listByTournament(tournamentId), categoriesApi.listByTournament(tournamentId), ]); + // Normalize: derive time_block_ids from time_blocks when the API omits the id array + const evts = rawEvts.map((e) => ({ + ...e, + time_block_ids: (e.time_blocks ?? []).map((b) => b.id), + })); setEvents(evts); setTimeBlocks(blocks); setCategories(cats); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load events"); } finally { - setLoading(false); + if (!silent) setLoading(false); } }, [tournamentId]); useEffect(() => { loadAll(); }, [loadAll]); + // Keep panel.event in sync with fresh data after any silent refresh + useEffect(() => { + setPanel((prev) => { + if (prev?.type !== "edit") return prev; + const updated = events.find((e) => e.id === prev.event.id); + return updated ? { type: "edit", event: updated } : prev; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [events]); + // ── Time block handlers ─────────────────────────────────────────────────── const handleAddBlock = async (data: TimeBlockCreate) => { await timeBlocksApi.create(tournamentId, data); - await loadAll(); + await loadAll(true); }; const handleEditBlock = async (id: number, data: Partial) => { await timeBlocksApi.update(tournamentId, id, data); - await loadAll(); + await loadAll(true); }; // ── Delete block (with 409 modal guard) ────────────────────────────────── @@ -286,17 +301,17 @@ export default function EventsPage() { } else { await eventsApi.create(tournamentId, data); } - await loadAll(); + await loadAll(true); }; const handleUpdateEvent = async (id: number, delta: Partial) => { await eventsApi.update(tournamentId, id, delta); - await loadAll(); + await loadAll(true); }; const handleCreateCategory = async (name: string) => { const cat = await categoriesApi.create(tournamentId, name); - await loadAll(); + await loadAll(true); return cat; }; @@ -308,7 +323,7 @@ export default function EventsPage() { const handleDeleteClick = async (block: TimeBlock) => { try { await timeBlocksApi.delete(tournamentId, block.id); - await loadAll(); + await loadAll(true); } catch (e) { if (e instanceof ApiError && e.status === 409) { const detail = e.detail as { affected_events?: AffectedEvent[] }; @@ -325,7 +340,7 @@ export default function EventsPage() { await timeBlocksApi.delete(tournamentId, deleteTarget.id, true); setDeleteTarget(null); setAffectedEvents([]); - await loadAll(); + await loadAll(true); }; // ── Render ──────────────────────────────────────────────────────────────── From cce583a1c2d0294494849882316cf881e9c235dc Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:13:46 -0700 Subject: [PATCH 041/100] fix: use model_fields_set to allow clearing nullable event fields via PATCH - replace model_dump(exclude_none=True) with explicit model_fields_set iteration - allows sending category_id: null (or any nullable field as null) to clear the value - fields absent from the request are still skipped; only explicitly sent fields are updated --- backend/app/api/routes/events.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/backend/app/api/routes/events.py b/backend/app/api/routes/events.py index a7ec4272..e813785b 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -148,9 +148,8 @@ def update_event( _require_write_permission(current_user, tournament_id, db) event = _get_event_or_404(event_id, tournament_id, db) - update_data = payload.model_dump(exclude_none=True, exclude={"time_block_ids"}) - for field, value in update_data.items(): - setattr(event, field, value) + for field in payload.model_fields_set - {"time_block_ids"}: + setattr(event, field, getattr(payload, field)) if payload.time_block_ids is not None: blocks = ( From 1b60fe679abd61c43b1834f0b2981621d257f210 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:14:02 -0700 Subject: [PATCH 042/100] fix: optimistic category selection and correct active highlight in dropdown - add optimistic state to CategoryCell so the cell updates instantly on click - revert to previous value if the API commit fails - derive display value, No category highlight, and per-category active state from optimistic value instead of stale prop - clear optimistic state once the prop syncs from the server --- frontend/components/events/EventTable.tsx | 52 ++++++++++++++++------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 2cd068be..80628173 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -48,7 +48,7 @@ const inputStyle: CSSProperties = { fontFamily: "var(--font-mono)", fontSize: "12px", color: "var(--color-text-primary)", - background: "var(--color-surface-raised)", + background: "var(--color-surface)", border: "1px solid var(--color-accent)", borderRadius: "var(--radius-sm)", outline: "none", @@ -145,10 +145,12 @@ function CategoryCell({ onCreateCategory: (name: string) => Promise; isReadOnly?: boolean; }) { - const [open, setOpen] = useState(false); - const [creating, setCreating] = useState(false); - const [newName, setNewName] = useState(""); - const [saving, setSaving] = useState(false); + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [saving, setSaving] = useState(false); + // optimistic: undefined = use prop value, anything else = pending commit + const [optimistic, setOptimistic] = useState(undefined); const ref = useRef(null); const inputRef = useRef(null); @@ -169,13 +171,24 @@ function CategoryCell({ return () => document.removeEventListener("mousedown", handler); }, [open]); - const cat = categories.find((c) => c.id === value); + // Clear optimistic once the prop catches up + useEffect(() => { + setOptimistic(undefined); + }, [value]); + + const displayValue = optimistic !== undefined ? optimistic : value; + const cat = categories.find((c) => c.id === displayValue); const catIdx = cat ? categories.indexOf(cat) : -1; const cv = catIdx >= 0 ? catColorVars(catIdx) : null; const handleSelect = async (id: number | null) => { setOpen(false); - await onCommit(id); + setOptimistic(id); + try { + await onCommit(id); + } catch { + setOptimistic(undefined); // revert on failure + } }; const handleCreate = async () => { @@ -206,7 +219,7 @@ function CategoryCell({ borderRadius: "var(--radius-sm)", padding: "2px 7px", cursor: isReadOnly ? "default" : "pointer", - maxWidth: "120px", + maxWidth: "180px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", @@ -223,7 +236,7 @@ function CategoryCell({ left: 0, zIndex: 200, minWidth: "160px", - background: "var(--color-surface-raised)", + background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", boxShadow: "0 4px 12px rgba(0,0,0,0.12)", @@ -240,8 +253,8 @@ function CategoryCell({ padding: "7px 10px", fontFamily: "var(--font-sans)", fontSize: "12px", - color: value === null ? "var(--color-accent)" : "var(--color-text-secondary)", - background: value === null ? "var(--color-accent-subtle)" : "transparent", + color: displayValue === null ? "var(--color-accent)" : "var(--color-text-secondary)", + background: displayValue === null ? "var(--color-accent-subtle)" : "transparent", border: "none", cursor: "pointer", }} @@ -255,11 +268,19 @@ function CategoryCell({ {categories.map((c, idx) => { const cv2 = catColorVars(idx); - const active = c.id === value; + const active = c.id === displayValue; return ( ))} + {available.length === 0 && ( +
+ All blocks assigned +
+ )}
)}
From 7dbd1b97279514e899a5b4b9f49b13bb8d63ea43 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:20:41 -0700 Subject: [PATCH 044/100] fix: show only block label on event cards, remove date/time metadata - simplify time block chips on EventCard to label only - remove fmtDateShort and fmtTime imports no longer used --- frontend/components/events/EventCard.tsx | 28 +++++++----------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/frontend/components/events/EventCard.tsx b/frontend/components/events/EventCard.tsx index 8cb78bd0..be8f6af5 100644 --- a/frontend/components/events/EventCard.tsx +++ b/frontend/components/events/EventCard.tsx @@ -1,7 +1,7 @@ "use client"; import { Event, TournamentCategory } from "@/lib/api"; -import { fmtDateShort, fmtTime, catColorVars } from "@/lib/formatters"; +import { catColorVars } from "@/lib/formatters"; // ─── Props ──────────────────────────────────────────────────────────────────── @@ -155,30 +155,18 @@ export function EventCard({ event, categories, onClick }: Props) { key={block.id} style={{ display: "inline-flex", - flexDirection: "column", - padding: "3px 8px", + padding: "2px 8px", background: "var(--color-accent-subtle)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-sm)", + fontFamily: "var(--font-sans)", + fontSize: "11px", + fontWeight: 600, + color: "var(--color-text-primary)", + lineHeight: "18px", }} > - - {block.label} - - - {fmtDateShort(block.date)} · {fmtTime(block.start)}–{fmtTime(block.end)} - + {block.label} )) )} From 764065e084ed2c9c1b43648483ed0f8ccbadb73c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:30:57 -0700 Subject: [PATCH 045/100] feat: add panel open/close animations and single-event fetch on open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - open side panel with slide-in (translateX 100%→0) and backdrop fade-in - close side panel with slide-out animation on x button, backdrop click, escape, discard, and save; onClose() fires via onAnimationEnd after exit animation completes - add isClosing state and triggerClose() to delay unmount until exit animation finishes - on panel open, fetch only the specific event by id instead of reloading all events; panel opens immediately with cached data then silently refreshes - after event save/update, patch only that event in local state instead of calling loadAll(true) --- .../dashboard/[tournamentId]/events/page.tsx | 35 ++++++++++++++--- frontend/components/events/EventSidePanel.tsx | 38 +++++++++++++++++-- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 5ade0bf9..e1fa7494 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -295,18 +295,43 @@ export default function EventsPage() { type PanelMode = { type: "add" } | { type: "edit"; event: Event } | null; const [panel, setPanel] = useState(null); + // Normalize a single freshly-fetched/updated event the same way loadAll does + const normalizeEvent = useCallback((e: Event) => ({ + ...e, + time_block_ids: (e.time_blocks ?? []).map((b) => b.id), + }), []); + + const patchEventsState = useCallback((updated: Event) => { + const normalized = normalizeEvent(updated); + setEvents((prev) => prev.map((e) => e.id === normalized.id ? normalized : e)); + }, [normalizeEvent]); + + // Open edit panel: show immediately with cached data, then refresh just that event + const openEditPanel = useCallback(async (event: Event) => { + setPanel({ type: "edit", event }); + try { + const fresh = await eventsApi.get(tournamentId, event.id); + const normalized = normalizeEvent(fresh); + setPanel({ type: "edit", event: normalized }); + setEvents((prev) => prev.map((e) => e.id === normalized.id ? normalized : e)); + } catch { + // keep the cached data already shown in the panel + } + }, [tournamentId, normalizeEvent]); + const handleSaveEvent = async (data: EventCreate) => { if (panel?.type === "edit") { - await eventsApi.update(tournamentId, panel.event.id, data); + const updated = await eventsApi.update(tournamentId, panel.event.id, data); + patchEventsState(updated); } else { await eventsApi.create(tournamentId, data); + await loadAll(true); } - await loadAll(true); }; const handleUpdateEvent = async (id: number, delta: Partial) => { - await eventsApi.update(tournamentId, id, delta); - await loadAll(true); + const updated = await eventsApi.update(tournamentId, id, delta); + patchEventsState(updated); }; const handleCreateCategory = async (name: string) => { @@ -397,7 +422,7 @@ export default function EventsPage() { setPanel({ type: "edit", event })} + onCardClick={openEditPanel} onAddClick={() => setPanel({ type: "add" })} /> )} diff --git a/frontend/components/events/EventSidePanel.tsx b/frontend/components/events/EventSidePanel.tsx index 3f45e3c8..0bdfb14f 100644 --- a/frontend/components/events/EventSidePanel.tsx +++ b/frontend/components/events/EventSidePanel.tsx @@ -387,8 +387,12 @@ export function EventSidePanel({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [discarding, setDiscarding] = useState(false); + const [isClosing, setIsClosing] = useState(false); const nameRef = useRef(null); + // Start exit animation; onClose() fires once animation ends + const triggerClose = useCallback(() => setIsClosing(true), []); + // Re-sync when the target event changes (e.g. switching between edit targets) useEffect(() => { const base = mode === "edit" && event ? fromEvent(event) : emptyForm(); @@ -413,9 +417,9 @@ export function EventSidePanel({ if (dirty && !isReadOnly) { setDiscarding(true); } else { - onClose(); + triggerClose(); } - }, [dirty, isReadOnly, onClose]); + }, [dirty, isReadOnly, triggerClose]); // Escape key closes (with guard) useEffect(() => { @@ -448,7 +452,7 @@ export function EventSidePanel({ setSavedBase(blank); setTimeout(() => nameRef.current?.focus(), 50); } else { - onClose(); + triggerClose(); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to save event"); @@ -465,6 +469,25 @@ export function EventSidePanel({ return ( <> + + {/* Backdrop */}
@@ -491,7 +517,11 @@ export function EventSidePanel({ display: "flex", flexDirection: "column", overflow: "hidden", + animation: isClosing + ? "sidePanelSlideOut 200ms cubic-bezier(0.55, 0, 1, 0.45) forwards" + : "sidePanelSlideIn 220ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", }} + onAnimationEnd={() => { if (isClosing) onClose(); }} > {/* ── Header ── */}
setDiscarding(false)}> Keep editing -
From 396cb4af9910c83e63784ea0cfdb68b167fcf75a Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:35:32 -0700 Subject: [PATCH 046/100] feat: add EventChip component for Timeline view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - positioned chip spanning n time-block columns (n × columnWidth - 8px) - three color modes: category (catColorVars), division (div-b/c/none tokens), type (standard/trial tokens) - trial events always render with dashed border regardless of color mode - location (building · room) shown only at columnWidth >= 155px - hover effect via brightness filter and shadow-sm --- frontend/components/events/EventChip.tsx | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 frontend/components/events/EventChip.tsx diff --git a/frontend/components/events/EventChip.tsx b/frontend/components/events/EventChip.tsx new file mode 100644 index 00000000..41aa6bf9 --- /dev/null +++ b/frontend/components/events/EventChip.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Event, TournamentCategory } from "@/lib/api"; +import { catColorVars } from "@/lib/formatters"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface EventChipProps { + event: Event; + categories: TournamentCategory[]; + colorBy: "category" | "division" | "type"; + columnWidth: number; // px — controls label truncation / location visibility + spanCount: number; // number of time-block columns this chip spans + onClick: () => void; +} + +// ─── Color resolver ─────────────────────────────────────────────────────────── + +interface ChipColors { + bg: string; + text: string; + border: string; + dashed: boolean; +} + +function resolveColors( + event: Event, + categories: TournamentCategory[], + colorBy: "category" | "division" | "type", +): ChipColors { + const isTrial = event.event_type === "trial"; + + if (colorBy === "category") { + const idx = categories.findIndex((c) => c.id === event.category_id); + if (idx >= 0) { + const cv = catColorVars(idx); + return { bg: cv.subtle, text: cv.text, border: cv.main, dashed: isTrial }; + } + // No category — fall back to a neutral accent + return { + bg: "var(--color-accent-subtle)", + text: "var(--color-text-primary)", + border: "var(--color-accent)", + dashed: isTrial, + }; + } + + if (colorBy === "division") { + const key = event.division ? event.division.toLowerCase() : "none"; + return { + bg: `var(--color-div-${key}-subtle)`, + text: `var(--color-div-${key}-text)`, + border: `var(--color-div-${key})`, + dashed: isTrial, + }; + } + + // colorBy === "type" + if (isTrial) { + return { + bg: "var(--color-type-trial-subtle)", + text: "var(--color-type-trial-text)", + border: "var(--color-type-trial)", + dashed: true, + }; + } + return { + bg: "var(--color-type-standard-subtle)", + text: "var(--color-type-standard-text)", + border: "var(--color-type-standard)", + dashed: false, + }; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function EventChip({ + event, + categories, + colorBy, + columnWidth, + spanCount, + onClick, +}: EventChipProps) { + const colors = resolveColors(event, categories, colorBy); + const width = spanCount * columnWidth - 8; + const showLoc = columnWidth >= 155; + + const location = [event.building, event.room].filter(Boolean).join(" · "); + + return ( +
{ e.stopPropagation(); onClick(); }} + style={{ + position: "absolute", + top: "5px", + bottom: "5px", + left: "4px", + width: `${width}px`, + background: colors.bg, + border: `1px ${colors.dashed ? "dashed" : "solid"} ${colors.border}`, + borderRadius: "var(--radius-sm)", + padding: "3px 7px", + cursor: "pointer", + overflow: "hidden", + display: "flex", + flexDirection: "column", + justifyContent: "center", + gap: "2px", + transition: "box-shadow var(--transition-fast), filter var(--transition-fast)", + boxSizing: "border-box", + }} + onMouseEnter={(e) => { + (e.currentTarget as HTMLDivElement).style.filter = "brightness(0.95)"; + (e.currentTarget as HTMLDivElement).style.boxShadow = "var(--shadow-sm)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.filter = ""; + (e.currentTarget as HTMLDivElement).style.boxShadow = ""; + }} + > + {/* Event name */} + + {event.name} + + + {/* Location — only at 155px+ column width */} + {showLoc && location && ( + + {location} + + )} +
+ ); +} From 2a772530852ff81313a98b0019a063963d38cd3c Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:36:54 -0700 Subject: [PATCH 047/100] fix: resolve Tournament type mismatch between useTournament and api - remove duplicate Tournament interface from useTournament.tsx - import and re-export Tournament from lib/api.ts instead - stale blocks field was incompatible with api.ts time_blocks field --- frontend/lib/useTournament.tsx | 24 ++---------------------- 1 file changed, 2 insertions(+), 22 deletions(-) diff --git a/frontend/lib/useTournament.tsx b/frontend/lib/useTournament.tsx index badd3963..c7869875 100644 --- a/frontend/lib/useTournament.tsx +++ b/frontend/lib/useTournament.tsx @@ -8,29 +8,9 @@ import { useCallback, ReactNode, } from "react"; -import { tournamentsApi } from "./api"; +import { tournamentsApi, Tournament } from "./api"; -export interface Tournament { - id: number; - name: string; - location: string | null; - start_date: string | null; - end_date: string | null; - blocks: Array<{ - number: number; - label: string; - date: string; - start: string; - end: string; - }>; - volunteer_schema: { - custom_fields: Array<{ key: string; label: string; type: string }>; - positions: Array<{ key: string; label: string; permissions: string[] }>; - }; - owner_id: number; - created_at: string; - updated_at: string; -} +export type { Tournament }; interface TournamentContextValue { tournaments: Tournament[]; From 8d733524725dd6d9afcb4ff9d22d9b33639f88d4 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:40:02 -0700 Subject: [PATCH 048/100] fix: stub out assigned_event_id cell until event assignment is implemented - replace m.assigned_event_id reference with hardcoded dash to fix type error - Membership type does not yet have assigned_event_id; will be wired in a future issue --- frontend/app/dashboard/[tournamentId]/volunteers/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app/dashboard/[tournamentId]/volunteers/page.tsx b/frontend/app/dashboard/[tournamentId]/volunteers/page.tsx index d48d0336..540aef91 100644 --- a/frontend/app/dashboard/[tournamentId]/volunteers/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/volunteers/page.tsx @@ -355,7 +355,7 @@ export default function VolunteersPage() { - {m.assigned_event_id != null ? String(m.assigned_event_id) : "—"} + {"—"}{/* TODO: wire assigned_event_id when event assignment is implemented */} {m.notes ?? "—"} {/* Extra data — wider, wrapping */} From ca70875ef47b26a9f785eb16491893ee67764262 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:41:49 -0700 Subject: [PATCH 049/100] fix: remove stale blocks field from NewTournamentModal create payload - blocks is not a valid field on Tournament create; time blocks are managed separately via the Time Blocks tab --- frontend/components/ui/NewTournamentModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/ui/NewTournamentModal.tsx b/frontend/components/ui/NewTournamentModal.tsx index 782808be..1c2a3046 100644 --- a/frontend/components/ui/NewTournamentModal.tsx +++ b/frontend/components/ui/NewTournamentModal.tsx @@ -37,7 +37,7 @@ export function NewTournamentModal({ onClose, onCreated }: NewTournamentModalPro location: location.trim() || null, start_date: startDate || null, end_date: endDate || null, - blocks: [], + }) onCreated(t) } catch { From 20fa51be43a9d3ad3fad58efbb98c903f0945204 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 00:55:56 -0700 Subject: [PATCH 050/100] feat: add EventTimeline component with zoom, grouping, and color-by controls - horizontally scrollable grid with time blocks as columns and events as EventChip rows - two-row sticky header: date groups (top) and block labels + time range at 110px+ (bottom) - four zoom levels: 70/110/155/210px; percentage readout; location shown in chip at 155px+ - grouping modes: by category, by building, A-Z - color-by dropdown: category / division / type passed down to EventChip - consecutive block runs per event rendered as separate chips - unscheduled strip pinned below main grid with italic labels - empty state when no time blocks exist - replace TimelinePlaceholder in page.tsx with EventTimeline - add style prop to EventChip for left-offset override in timeline context --- .../dashboard/[tournamentId]/events/page.tsx | 32 +- frontend/components/events/EventChip.tsx | 3 + frontend/components/events/EventTimeline.tsx | 578 ++++++++++++++++++ 3 files changed, 589 insertions(+), 24 deletions(-) create mode 100644 frontend/components/events/EventTimeline.tsx diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index e1fa7494..d72ee494 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -20,6 +20,7 @@ import { DeleteBlockModal, AffectedEvent } from "@/components/events/DeleteBlock import { EventSidePanel } from "@/components/events/EventSidePanel"; import { EventCardGrid } from "@/components/events/EventCardGrid"; import { EventTable } from "@/components/events/EventTable"; +import { EventTimeline } from "@/components/events/EventTimeline"; // ─── Tab type ───────────────────────────────────────────────────────────────── @@ -200,29 +201,6 @@ function ImportBar() { ); } -// ─── Placeholder panels (replaced in subsequent steps) ──────────────────────── - -function TimelinePlaceholder() { - return ( -
- Timeline view — coming soon -
- ); -} - - // ─── Inline icons ───────────────────────────────────────────────────────────── // ─── Page ───────────────────────────────────────────────────────────────────── @@ -415,7 +393,13 @@ export default function EventsPage() { ) : ( <> {activeTab === "timeline" && ( - + setPanel({ type: "add" })} + /> )} {activeTab === "cards" && ( diff --git a/frontend/components/events/EventChip.tsx b/frontend/components/events/EventChip.tsx index 41aa6bf9..2f1fe707 100644 --- a/frontend/components/events/EventChip.tsx +++ b/frontend/components/events/EventChip.tsx @@ -12,6 +12,7 @@ export interface EventChipProps { columnWidth: number; // px — controls label truncation / location visibility spanCount: number; // number of time-block columns this chip spans onClick: () => void; + style?: React.CSSProperties; // overrides for outer div (e.g. left offset in timeline) } // ─── Color resolver ─────────────────────────────────────────────────────────── @@ -81,6 +82,7 @@ export function EventChip({ columnWidth, spanCount, onClick, + style: styleOverride, }: EventChipProps) { const colors = resolveColors(event, categories, colorBy); const width = spanCount * columnWidth - 8; @@ -98,6 +100,7 @@ export function EventChip({ bottom: "5px", left: "4px", width: `${width}px`, + ...styleOverride, background: colors.bg, border: `1px ${colors.dashed ? "dashed" : "solid"} ${colors.border}`, borderRadius: "var(--radius-sm)", diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx new file mode 100644 index 00000000..c6404955 --- /dev/null +++ b/frontend/components/events/EventTimeline.tsx @@ -0,0 +1,578 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Event, TimeBlock, TournamentCategory } from "@/lib/api"; +import { fmtDate, fmtTime } from "@/lib/formatters"; +import { EventChip } from "@/components/events/EventChip"; +import { Button } from "@/components/ui/Button"; +import { IconPlus } from "@/components/ui/Icons"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const ZOOM_LEVELS = [70, 110, 155, 210] as const; +const LABEL_W = 160; // px — sticky left label column width +const ROW_H = 44; // px — event row height +const DATE_ROW_H = 26; // px — top header row (date groups) +const BLOCK_ROW_H = 32; // px — bottom header row (block labels) + +type ColorBy = "category" | "division" | "type"; +type GroupBy = "category" | "building" | "az"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + events: Event[]; + timeBlocks: TimeBlock[]; + categories: TournamentCategory[]; + onEventClick: (event: Event) => void; + onAddClick: () => void; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +interface Run { startIdx: number; spanCount: number } + +function getRuns(event: Event, timeBlocks: TimeBlock[]): Run[] { + const indices = (event.time_block_ids ?? []) + .map((id) => timeBlocks.findIndex((b) => b.id === id)) + .filter((i) => i >= 0) + .sort((a, b) => a - b); + if (indices.length === 0) return []; + const runs: Run[] = []; + let s = indices[0], e = indices[0]; + for (let i = 1; i < indices.length; i++) { + if (indices[i] === e + 1) { e = indices[i]; } + else { runs.push({ startIdx: s, spanCount: e - s + 1 }); s = e = indices[i]; } + } + runs.push({ startIdx: s, spanCount: e - s + 1 }); + return runs; +} + +interface Group { key: string; label: string; events: Event[] } + +function buildGroups( + events: Event[], + categories: TournamentCategory[], + groupBy: GroupBy, +): Group[] { + if (groupBy === "az") { + return [{ key: "all", label: "", events: [...events].sort((a, b) => a.name.localeCompare(b.name)) }]; + } + if (groupBy === "category") { + const map = new Map(); + categories.forEach((cat) => + map.set(String(cat.id), { key: String(cat.id), label: cat.name, events: [] }), + ); + map.set("null", { key: "null", label: "Uncategorized", events: [] }); + events.forEach((ev) => { + const key = ev.category_id !== null ? String(ev.category_id) : "null"; + map.get(key)?.events.push(ev); + }); + return [...map.values()].filter((g) => g.events.length > 0); + } + // groupBy === "building" + const map = new Map(); + events.forEach((ev) => { + const key = ev.building ?? ""; + const label = ev.building ?? "No building"; + if (!map.has(key)) map.set(key, { key, label, events: [] }); + map.get(key)!.events.push(ev); + }); + return [...map.values()] + .map((g) => ({ ...g, events: g.events.sort((a, b) => a.name.localeCompare(b.name)) })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +interface DateGroup { date: string; label: string; blockCount: number } + +function buildDateGroups(timeBlocks: TimeBlock[]): DateGroup[] { + const groups: DateGroup[] = []; + for (const b of timeBlocks) { + const last = groups[groups.length - 1]; + if (last && last.date === b.date) { last.blockCount++; } + else { groups.push({ date: b.date, label: fmtDate(b.date), blockCount: 1 }); } + } + return groups; +} + +// ─── Shared cell styles ─────────────────────────────────────────────────────── + +const borderR: React.CSSProperties = { borderRight: "1px solid var(--color-border)" }; +const borderB: React.CSSProperties = { borderBottom: "1px solid var(--color-border)" }; + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function EventTimeline({ events, timeBlocks, categories, onEventClick, onAddClick }: Props) { + const [zoomIdx, setZoomIdx] = useState(1); // default 110px + const [groupBy, setGroupBy] = useState("category"); + const [colorBy, setColorBy] = useState("category"); + + const colW = ZOOM_LEVELS[zoomIdx]; + const zoomPct = Math.round((colW / 70) * 100); + + const scheduled = useMemo(() => events.filter((e) => (e.time_block_ids ?? []).length > 0), [events]); + const unscheduled = useMemo(() => events.filter((e) => (e.time_block_ids ?? []).length === 0) + .sort((a, b) => a.name.localeCompare(b.name)), [events]); + + const groups = useMemo(() => buildGroups(scheduled, categories, groupBy), [scheduled, categories, groupBy]); + const dateGroups = useMemo(() => buildDateGroups(timeBlocks), [timeBlocks]); + + const gridW = LABEL_W + timeBlocks.length * colW; + + // ── Shared sub-styles ────────────────────────────────────────────────────── + + const labelCell: React.CSSProperties = { + width: LABEL_W, + flexShrink: 0, + position: "sticky", + left: 0, + zIndex: 5, + background: "var(--color-bg)", + display: "flex", + alignItems: "center", + padding: "0 12px", + overflow: "hidden", + ...borderR, + }; + + const controlSelect: React.CSSProperties = { + height: "30px", + padding: "0 8px", + fontFamily: "var(--font-sans)", + fontSize: "12px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + cursor: "pointer", + outline: "none", + }; + + const zoomBtn = (disabled: boolean): React.CSSProperties => ({ + width: "26px", + height: "26px", + display: "flex", + alignItems: "center", + justifyContent: "center", + fontFamily: "var(--font-sans)", + fontSize: "16px", + fontWeight: 400, + color: disabled ? "var(--color-text-tertiary)" : "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + cursor: disabled ? "not-allowed" : "pointer", + }); + + // ── Empty state ──────────────────────────────────────────────────────────── + + if (timeBlocks.length === 0) { + return ( +
+ +
+ Add time blocks to see the timeline. +
+
+ ); + } + + return ( +
+ {/* ── Controls bar ── */} + + + {/* ── Scrollable grid ── */} +
+
+ + {/* ── Sticky two-row header ── */} +
+ + {/* Date row */} +
+
+ {dateGroups.map((dg) => ( +
+ + {dg.label} + +
+ ))} +
+ + {/* Block row */} +
+
+ {timeBlocks.map((block) => ( +
+ + {block.label} + + {colW >= 110 && ( + + {fmtTime(block.start)}–{fmtTime(block.end)} + + )} +
+ ))} +
+
+ + {/* ── Scheduled groups ── */} + {groups.map((group) => ( +
+ + {/* Group header row */} + {groupBy !== "az" && ( +
+
+ + {group.label} + +
+
+ )} + + {/* Event rows */} + {group.events.map((event) => { + const runs = getRuns(event, timeBlocks); + return ( +
+ {/* Label */} +
+ + {event.name} + +
+ + {/* Block area */} +
+ {/* Column separators */} + {timeBlocks.map((_, i) => ( +
+ ))} + + {/* Chips */} + {runs.map((run) => ( + onEventClick(event)} + style={{ left: run.startIdx * colW + 4 }} + /> + ))} +
+
+ ); + })} +
+ ))} + + {/* ── Unscheduled strip ── */} + {unscheduled.length > 0 && ( +
+
+
+ + Unscheduled ({unscheduled.length}) + +
+
+ + {unscheduled.map((event) => ( +
+
onEventClick(event)} title={event.name}> + + {event.name} + +
+
+
+ ))} +
+ )} + + {/* Empty scheduled state */} + {scheduled.length === 0 && ( +
+ No scheduled events yet. + +
+ )} + +
+
+
+ ); +} + +// ─── Controls bar (extracted to keep render clean) ──────────────────────────── + +function ControlsBar({ + zoomIdx, setZoomIdx, zoomPct, + groupBy, setGroupBy, + colorBy, setColorBy, + eventCount, onAddClick, + controlSelect, zoomBtn, +}: { + zoomIdx: number; + setZoomIdx: (n: number) => void; + zoomPct: number; + groupBy: GroupBy; + setGroupBy: (g: GroupBy) => void; + colorBy: ColorBy; + setColorBy: (c: ColorBy) => void; + eventCount: number; + onAddClick: () => void; + controlSelect: React.CSSProperties; + zoomBtn: (disabled: boolean) => React.CSSProperties; +}) { + return ( +
+ {/* Zoom controls */} +
+ + + {zoomPct}% + + +
+ + {/* Divider */} +
+ + {/* Group by */} + + + {/* Color by */} + + +
+ + + {eventCount} event{eventCount !== 1 ? "s" : ""} + + + +
+ ); +} From 72a72d32a73669055c8fbbb936a14821214985aa Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 01:06:58 -0700 Subject: [PATCH 051/100] fix: timeline group labels, division format, category colors, and sticky header - left column now shows group label (category/building/letter) spanning all rows in the group instead of event names - a-z grouping uses first letter of event name as group label - division appended to chip and unscheduled label without dot separator (e.g. 'Anatomy B') - category group label cells colored with catColorVars subtle/text/border tokens - thicker 2px bottom border between groups for visual separation - fix sticky header by changing outer container overflow from hidden to clip; hidden was creating a scroll context that trapped the sticky element relative to its nearest ancestor rather than the main scroll container --- frontend/components/events/EventTimeline.tsx | 685 +++++++------------ 1 file changed, 262 insertions(+), 423 deletions(-) diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx index c6404955..c58a6015 100644 --- a/frontend/components/events/EventTimeline.tsx +++ b/frontend/components/events/EventTimeline.tsx @@ -1,8 +1,8 @@ "use client"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Event, TimeBlock, TournamentCategory } from "@/lib/api"; -import { fmtDate, fmtTime } from "@/lib/formatters"; +import { fmtDate, fmtTime, catColorVars } from "@/lib/formatters"; import { EventChip } from "@/components/events/EventChip"; import { Button } from "@/components/ui/Button"; import { IconPlus } from "@/components/ui/Icons"; @@ -10,10 +10,10 @@ import { IconPlus } from "@/components/ui/Icons"; // ─── Constants ──────────────────────────────────────────────────────────────── const ZOOM_LEVELS = [70, 110, 155, 210] as const; -const LABEL_W = 160; // px — sticky left label column width +const LABEL_W = 160; // px — sticky left label column const ROW_H = 44; // px — event row height -const DATE_ROW_H = 26; // px — top header row (date groups) -const BLOCK_ROW_H = 32; // px — bottom header row (block labels) +const DATE_ROW_H = 26; // px — top header row +const BLOCK_ROW_H = 32; // px — bottom header row type ColorBy = "category" | "division" | "type"; type GroupBy = "category" | "building" | "az"; @@ -56,7 +56,13 @@ function buildGroups( groupBy: GroupBy, ): Group[] { if (groupBy === "az") { - return [{ key: "all", label: "", events: [...events].sort((a, b) => a.name.localeCompare(b.name)) }]; + const map = new Map(); + [...events].sort((a, b) => a.name.localeCompare(b.name)).forEach((ev) => { + const key = ev.name[0]?.toUpperCase() ?? "#"; + if (!map.has(key)) map.set(key, { key, label: key, events: [] }); + map.get(key)!.events.push(ev); + }); + return [...map.values()].sort((a, b) => a.key.localeCompare(b.key)); } if (groupBy === "category") { const map = new Map(); @@ -79,7 +85,7 @@ function buildGroups( map.get(key)!.events.push(ev); }); return [...map.values()] - .map((g) => ({ ...g, events: g.events.sort((a, b) => a.name.localeCompare(b.name)) })) + .map((g) => ({ ...g, events: [...g.events].sort((a, b) => a.name.localeCompare(b.name)) })) .sort((a, b) => a.label.localeCompare(b.label)); } @@ -95,44 +101,49 @@ function buildDateGroups(timeBlocks: TimeBlock[]): DateGroup[] { return groups; } -// ─── Shared cell styles ─────────────────────────────────────────────────────── - -const borderR: React.CSSProperties = { borderRight: "1px solid var(--color-border)" }; -const borderB: React.CSSProperties = { borderBottom: "1px solid var(--color-border)" }; - // ─── Component ──────────────────────────────────────────────────────────────── export function EventTimeline({ events, timeBlocks, categories, onEventClick, onAddClick }: Props) { - const [zoomIdx, setZoomIdx] = useState(1); // default 110px - const [groupBy, setGroupBy] = useState("category"); - const [colorBy, setColorBy] = useState("category"); + const [zoomIdx, setZoomIdx] = useState(1); + const [groupBy, setGroupBy] = useState("category"); + const [colorBy, setColorBy] = useState("category"); + + // Scroll sync: header scrolls horizontally with content + const headerScrollRef = useRef(null); + const contentScrollRef = useRef(null); + const onContentScroll = () => { + if (headerScrollRef.current && contentScrollRef.current) { + headerScrollRef.current.scrollLeft = contentScrollRef.current.scrollLeft; + } + }; - const colW = ZOOM_LEVELS[zoomIdx]; - const zoomPct = Math.round((colW / 70) * 100); + const colW = ZOOM_LEVELS[zoomIdx]; + const zoomPct = Math.round((colW / 70) * 100); + const blockW = timeBlocks.length * colW; + const gridW = LABEL_W + blockW; const scheduled = useMemo(() => events.filter((e) => (e.time_block_ids ?? []).length > 0), [events]); - const unscheduled = useMemo(() => events.filter((e) => (e.time_block_ids ?? []).length === 0) - .sort((a, b) => a.name.localeCompare(b.name)), [events]); - + const unscheduled = useMemo( + () => [...events.filter((e) => (e.time_block_ids ?? []).length === 0)].sort((a, b) => a.name.localeCompare(b.name)), + [events], + ); const groups = useMemo(() => buildGroups(scheduled, categories, groupBy), [scheduled, categories, groupBy]); const dateGroups = useMemo(() => buildDateGroups(timeBlocks), [timeBlocks]); - const gridW = LABEL_W + timeBlocks.length * colW; - - // ── Shared sub-styles ────────────────────────────────────────────────────── + // ── Shared styles ────────────────────────────────────────────────────────── const labelCell: React.CSSProperties = { - width: LABEL_W, - flexShrink: 0, - position: "sticky", - left: 0, - zIndex: 5, - background: "var(--color-bg)", - display: "flex", - alignItems: "center", - padding: "0 12px", - overflow: "hidden", - ...borderR, + width: LABEL_W, + flexShrink: 0, + position: "sticky", + left: 0, + zIndex: 5, + background: "var(--color-bg)", + borderRight: "1px solid var(--color-border)", + display: "flex", + alignItems: "center", + padding: "0 12px", + overflow: "hidden", }; const controlSelect: React.CSSProperties = { @@ -148,431 +159,259 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on outline: "none", }; - const zoomBtn = (disabled: boolean): React.CSSProperties => ({ - width: "26px", - height: "26px", - display: "flex", - alignItems: "center", + const zoomBtnStyle = (disabled: boolean): React.CSSProperties => ({ + width: "26px", + height: "26px", + display: "flex", + alignItems: "center", justifyContent: "center", - fontFamily: "var(--font-sans)", - fontSize: "16px", - fontWeight: 400, - color: disabled ? "var(--color-text-tertiary)" : "var(--color-text-primary)", - background: "var(--color-surface)", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius-sm)", - cursor: disabled ? "not-allowed" : "pointer", + fontFamily: "var(--font-sans)", + fontSize: "16px", + color: disabled ? "var(--color-text-tertiary)" : "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + cursor: disabled ? "not-allowed" : "pointer", }); + // ── Controls bar ────────────────────────────────────────────────────────── + + const controls = ( +
+
+ + + {zoomPct}% + + +
+ +
+ + + + + +
+ + + {events.length} event{events.length !== 1 ? "s" : ""} + + + +
+ ); + // ── Empty state ──────────────────────────────────────────────────────────── if (timeBlocks.length === 0) { return (
- -
+ {controls} +
Add time blocks to see the timeline.
); } + // ── Header rows (rendered inside scroll-synced div) ─────────────────────── + + const headerRows = ( +
+ {/* Date row */} +
+
+ {dateGroups.map((dg) => ( +
+ + {dg.label} + +
+ ))} +
+ + {/* Block row */} +
+
+ {timeBlocks.map((block) => ( +
+ + {block.label} + + {colW >= 110 && ( + + {fmtTime(block.start)}–{fmtTime(block.end)} + + )} +
+ ))} +
+
+ ); + + // ── Render ───────────────────────────────────────────────────────────────── + return (
- {/* ── Controls bar ── */} - - - {/* ── Scrollable grid ── */} -
-
- - {/* ── Sticky two-row header ── */} -
- - {/* Date row */} -
-
- {dateGroups.map((dg) => ( -
- - {dg.label} - -
- ))} -
- - {/* Block row */} -
-
- {timeBlocks.map((block) => ( -
- - {block.label} - - {colW >= 110 && ( - - {fmtTime(block.start)}–{fmtTime(block.end)} - - )} -
- ))} -
-
+ {controls} - {/* ── Scheduled groups ── */} - {groups.map((group) => ( -
+
- {/* Group header row */} - {groupBy !== "az" && ( -
-
- - {group.label} - -
-
- )} - - {/* Event rows */} - {group.events.map((event) => { - const runs = getRuns(event, timeBlocks); - return ( -
- {/* Label */} -
- - {event.name} - -
+ {/* ── Sticky header — overflowX hidden, synced by JS ── */} +
+ {headerRows} +
- {/* Block area */} -
- {/* Column separators */} - {timeBlocks.map((_, i) => ( -
- ))} - - {/* Chips */} - {runs.map((run) => ( - onEventClick(event)} - style={{ left: run.startIdx * colW + 4 }} - /> - ))} -
-
- ); - })} -
- ))} - - {/* ── Unscheduled strip ── */} - {unscheduled.length > 0 && ( -
-
+ {/* ── Scrollable content ── */} +
+
+ + {/* Groups */} + {groups.map((group, groupIdx) => { + // Category color for label cell + let labelBg = "var(--color-bg)"; + let labelColor = "var(--color-text-secondary)"; + let labelBorderR = "1px solid var(--color-border)"; + if (groupBy === "category" && group.key !== "null") { + const catIdx = categories.findIndex((c) => String(c.id) === group.key); + if (catIdx >= 0) { + const cv = catColorVars(catIdx); + labelBg = cv.subtle; + labelColor = cv.text; + labelBorderR = `1px solid ${cv.main}`; + } + } + return ( +
0 ? "1px solid var(--color-border)" : undefined, + }} + > + {/* Group label — spans all event rows in this group */}
- Unscheduled ({unscheduled.length}) + {group.label}
+ + {/* Event rows */} +
+ {group.events.map((event, idx) => { + const runs = getRuns(event, timeBlocks); + const isLast = idx === group.events.length - 1; + const chipLabel = event.division + ? `${event.name} ${event.division}` + : event.name; + return ( +
+ {/* Column separators */} + {timeBlocks.map((_, i) => ( +
+ ))} + + {/* Chips */} + {runs.map((run) => ( + onEventClick(event)} + style={{ left: run.startIdx * colW + 4 }} + /> + ))} +
+ ); + })} +
+ ); })} - {unscheduled.map((event) => ( -
-
onEventClick(event)} title={event.name}> - - {event.name} + {/* Empty scheduled state */} + {scheduled.length === 0 && ( +
+ No scheduled events yet. + +
+ )} + + {/* Unscheduled strip */} + {unscheduled.length > 0 && ( +
+
+
+ + Unscheduled ({unscheduled.length})
-
- ))} -
- )} - - {/* Empty scheduled state */} - {scheduled.length === 0 && ( -
- No scheduled events yet. - -
- )} -
-
-
- ); -} + {unscheduled.map((event) => ( +
+
onEventClick(event)} title={event.name}> + + {event.name}{event.division ? ` ${event.division}` : ""} + +
+
+
+ ))} +
+ )} -// ─── Controls bar (extracted to keep render clean) ──────────────────────────── - -function ControlsBar({ - zoomIdx, setZoomIdx, zoomPct, - groupBy, setGroupBy, - colorBy, setColorBy, - eventCount, onAddClick, - controlSelect, zoomBtn, -}: { - zoomIdx: number; - setZoomIdx: (n: number) => void; - zoomPct: number; - groupBy: GroupBy; - setGroupBy: (g: GroupBy) => void; - colorBy: ColorBy; - setColorBy: (c: ColorBy) => void; - eventCount: number; - onAddClick: () => void; - controlSelect: React.CSSProperties; - zoomBtn: (disabled: boolean) => React.CSSProperties; -}) { - return ( -
- {/* Zoom controls */} -
- - - {zoomPct}% - - +
+
- - {/* Divider */} -
- - {/* Group by */} - - - {/* Color by */} - - -
- - - {eventCount} event{eventCount !== 1 ? "s" : ""} - - -
); } From 1cacccd5813e524125d62c65c392437a74e83058 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 10:59:51 -0700 Subject: [PATCH 052/100] fix: include year in date formatter constructors - pass year to new Date() in fmtDate and fmtDateShort so weekday is calculated correctly - previously used year 0 (1900), causing wrong weekday output for modern dates --- frontend/lib/formatters.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/lib/formatters.ts b/frontend/lib/formatters.ts index 54ee84fc..25bc8d54 100644 --- a/frontend/lib/formatters.ts +++ b/frontend/lib/formatters.ts @@ -11,8 +11,8 @@ export function fmtTime(hhmm: string): string { /** "YYYY-MM-DD" → "Wed, Mar 15" */ export function fmtDate(yyyymmdd: string): string { - const [, mo, d] = yyyymmdd.split("-").map(Number); - return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + const [yr, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(yr, mo - 1, d).toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", @@ -21,8 +21,8 @@ export function fmtDate(yyyymmdd: string): string { /** "YYYY-MM-DD" → "Mar 15" */ export function fmtDateShort(yyyymmdd: string): string { - const [, mo, d] = yyyymmdd.split("-").map(Number); - return new Date(0, mo - 1, d).toLocaleDateString("en-US", { + const [yr, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(yr, mo - 1, d).toLocaleDateString("en-US", { month: "short", day: "numeric", }); From 1468908fdc809d4ea2398ae1510c341536b01696 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 11:58:24 -0700 Subject: [PATCH 053/100] feat: move controls into sticky header and fix unscheduled subheader sticking - pull zoom, groupBy/colorBy dropdowns, and Add Event button into the sticky header bar so controls remain visible while scrolling - fix transparent gap above sticky header by using top: -22px to offset the page's 22px top padding - lift unscheduled subheader out of overflow-x:auto content div so vertical sticky positioning works - give unscheduled rows their own scroll-synced container (unscheduledSubRef + unscheduledContentRef) - replace individual scroll handler with shared syncScroll helper that keeps all four scroll regions in sync --- frontend/components/events/EventTimeline.tsx | 250 +++++++++++-------- 1 file changed, 149 insertions(+), 101 deletions(-) diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx index c58a6015..e42b7d4b 100644 --- a/frontend/components/events/EventTimeline.tsx +++ b/frontend/components/events/EventTimeline.tsx @@ -9,11 +9,14 @@ import { IconPlus } from "@/components/ui/Icons"; // ─── Constants ──────────────────────────────────────────────────────────────── -const ZOOM_LEVELS = [70, 110, 155, 210] as const; -const LABEL_W = 160; // px — sticky left label column -const ROW_H = 44; // px — event row height -const DATE_ROW_H = 26; // px — top header row -const BLOCK_ROW_H = 32; // px — bottom header row +const ZOOM_LEVELS = [70, 110, 155, 210] as const; +const LABEL_W = 160; // px — sticky left label column +const ROW_H = 44; // px — event row height +const DATE_ROW_H = 26; // px — top header row +const BLOCK_ROW_H = 32; // px — bottom header row +const CONTROLS_H = 50; // px — controls bar height (incl. padding) +// Total height of the sticky header (controls + 1px border + date row + block row) +const STICKY_H = CONTROLS_H + 1 + DATE_ROW_H + BLOCK_ROW_H; type ColorBy = "category" | "division" | "type"; type GroupBy = "category" | "building" | "az"; @@ -108,19 +111,25 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on const [groupBy, setGroupBy] = useState("category"); const [colorBy, setColorBy] = useState("category"); - // Scroll sync: header scrolls horizontally with content - const headerScrollRef = useRef(null); - const contentScrollRef = useRef(null); - const onContentScroll = () => { - if (headerScrollRef.current && contentScrollRef.current) { - headerScrollRef.current.scrollLeft = contentScrollRef.current.scrollLeft; + // Scroll sync: all overflow-hidden sections track scrollLeft of the active scroller + const headerScrollRef = useRef(null); + const contentScrollRef = useRef(null); + const unscheduledSubRef = useRef(null); + const unscheduledContentRef = useRef(null); + + const syncScroll = (scrollLeft: number) => { + for (const ref of [headerScrollRef, contentScrollRef, unscheduledSubRef, unscheduledContentRef]) { + if (ref.current && ref.current.scrollLeft !== scrollLeft) + ref.current.scrollLeft = scrollLeft; } }; + const onContentScroll = () => syncScroll(contentScrollRef.current?.scrollLeft ?? 0); + const onUnscheduledScroll = () => syncScroll(unscheduledContentRef.current?.scrollLeft ?? 0); - const colW = ZOOM_LEVELS[zoomIdx]; - const zoomPct = Math.round((colW / 70) * 100); - const blockW = timeBlocks.length * colW; - const gridW = LABEL_W + blockW; + const colW = ZOOM_LEVELS[zoomIdx]; + const zoomPct = Math.round((colW / 70) * 100); + const blockW = timeBlocks.length * colW; + const gridW = LABEL_W + blockW; const scheduled = useMemo(() => events.filter((e) => (e.time_block_ids ?? []).length > 0), [events]); const unscheduled = useMemo( @@ -174,10 +183,19 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on cursor: disabled ? "not-allowed" : "pointer", }); - // ── Controls bar ────────────────────────────────────────────────────────── - - const controls = ( -
+ // ── Controls bar (lives inside the sticky header) ───────────────────────── + + const controlsBar = ( +
@@ -214,20 +232,7 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on
); - // ── Empty state ──────────────────────────────────────────────────────────── - - if (timeBlocks.length === 0) { - return ( -
- {controls} -
- Add time blocks to see the timeline. -
-
- ); - } - - // ── Header rows (rendered inside scroll-synced div) ─────────────────────── + // ── Header rows (date + block, rendered inside scroll-synced div) ───────── const headerRows = (
@@ -262,52 +267,72 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on
); - // ── Render ───────────────────────────────────────────────────────────────── + // ── Shared outer container style ─────────────────────────────────────────── - return ( -
- {controls} + const outerStyle: React.CSSProperties = { + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + overflow: "clip", + }; -
+ // ── Empty state ──────────────────────────────────────────────────────────── - {/* ── Sticky header — overflowX hidden, synced by JS ── */} -
- {headerRows} + if (timeBlocks.length === 0) { + return ( +
+
+ {controlsBar}
+
+ Add time blocks to see the timeline. +
+
+ ); + } + + // ── Render ───────────────────────────────────────────────────────────────── - {/* ── Scrollable content ── */} -
-
- - {/* Groups */} - {groups.map((group, groupIdx) => { - // Category color for label cell - let labelBg = "var(--color-bg)"; - let labelColor = "var(--color-text-secondary)"; - let labelBorderR = "1px solid var(--color-border)"; - if (groupBy === "category" && group.key !== "null") { - const catIdx = categories.findIndex((c) => String(c.id) === group.key); - if (catIdx >= 0) { - const cv = catColorVars(catIdx); - labelBg = cv.subtle; - labelColor = cv.text; - labelBorderR = `1px solid ${cv.main}`; - } + return ( +
+ + {/* ── Sticky header: controls + date/block rows ── */} +
+ {controlsBar} + {headerRows} +
+ + {/* ── Scrollable content: scheduled groups ── */} +
+
+ + {/* Groups */} + {groups.map((group, groupIdx) => { + let labelBg = "var(--color-bg)"; + let labelColor = "var(--color-text-secondary)"; + let labelBorderR = "1px solid var(--color-border)"; + if (groupBy === "category" && group.key !== "null") { + const catIdx = categories.findIndex((c) => String(c.id) === group.key); + if (catIdx >= 0) { + const cv = catColorVars(catIdx); + labelBg = cv.subtle; + labelColor = cv.text; + labelBorderR = `1px solid ${cv.main}`; } - return ( + } + return (
0 ? "1px solid var(--color-border)" : undefined, + borderTop: groupIdx > 0 ? "1px solid var(--color-border)" : undefined, }} > {/* Group label — spans all event rows in this group */} @@ -337,8 +362,8 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on {/* Event rows */}
{group.events.map((event, idx) => { - const runs = getRuns(event, timeBlocks); - const isLast = idx === group.events.length - 1; + const runs = getRuns(event, timeBlocks); + const isLast = idx === group.events.length - 1; const chipLabel = event.division ? `${event.name} ${event.division}` : event.name; @@ -375,43 +400,66 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on })}
- ); })} + ); + })} + + {/* Empty scheduled state */} + {scheduled.length === 0 && ( +
+ No scheduled events yet. + +
+ )} + +
+
- {/* Empty scheduled state */} - {scheduled.length === 0 && ( -
- No scheduled events yet. - + {/* ── Unscheduled section ── */} + {unscheduled.length > 0 && ( + <> + {/* Sticky subheader — lifted out of the overflow-x:auto content div so vertical sticky works */} +
+
+
+ + Unscheduled ({unscheduled.length}) +
- )} +
+
- {/* Unscheduled strip */} - {unscheduled.length > 0 && ( -
-
-
- - Unscheduled ({unscheduled.length}) + {/* Unscheduled rows — own scroll-synced container */} +
+
+ {unscheduled.map((event) => ( +
+
onEventClick(event)} title={event.name}> + + {event.name}{event.division ? ` ${event.division}` : ""}
+
- - {unscheduled.map((event) => ( -
-
onEventClick(event)} title={event.name}> - - {event.name}{event.division ? ` ${event.division}` : ""} - -
-
-
- ))} -
- )} - + ))} +
-
-
+ + )} +
); } From f8ff7a03dbe4f7215340607e46925297ffc5456b Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 14:57:13 -0700 Subject: [PATCH 054/100] feat: add CsvImportBar with import preview modal - build CsvImportBar component with client-side CSV parsing (quoted fields, case-insensitive headers) - preview modal shows valid row count, new categories to auto-create, unknown blocks to skip, and per-row validation errors - import flow creates new categories first, then events sequentially with progress indicator - help popover with column reference table and required field markers - template CSV download button - replace static ImportBar placeholder in events page with wired CsvImportBar --- .../dashboard/[tournamentId]/events/page.tsx | 129 +-- frontend/components/events/CsvImportBar.tsx | 773 ++++++++++++++++++ 2 files changed, 780 insertions(+), 122 deletions(-) create mode 100644 frontend/components/events/CsvImportBar.tsx diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index d72ee494..0d580290 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -14,7 +14,7 @@ import { TournamentCategory, } from "@/lib/api"; import { PageHeader } from "@/components/ui/PageHeader"; -import { IconUpload, IconSheets } from "@/components/ui/Icons"; +import { CsvImportBar } from "@/components/events/CsvImportBar"; import { TimeBlocksTable } from "@/components/events/TimeBlocksTable"; import { DeleteBlockModal, AffectedEvent } from "@/components/events/DeleteBlockModal"; import { EventSidePanel } from "@/components/events/EventSidePanel"; @@ -83,126 +83,6 @@ function TabBar({ ); } -// ─── Import bar ─────────────────────────────────────────────────────────────── - -function ImportBar() { - return ( -
- - Import - - - {/* CSV upload */} - - - {/* Help icon */} - - -
- - {/* Google Sheets — disabled until Sheets feature lands */} - -
- ); -} - -// ─── Inline icons ───────────────────────────────────────────────────────────── - // ─── Page ───────────────────────────────────────────────────────────────────── export default function EventsPage() { @@ -355,7 +235,12 @@ export default function EventsPage() { subtitle="Manage tournament events, schedule time blocks, and track locations." /> - + loadAll(true)} + /> diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx new file mode 100644 index 00000000..1515c3ec --- /dev/null +++ b/frontend/components/events/CsvImportBar.tsx @@ -0,0 +1,773 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { IconUpload, IconSheets, IconWarning } from "@/components/ui/Icons"; +import { + eventsApi, + categoriesApi, + EventCreate, + TimeBlock, + TournamentCategory, +} from "@/lib/api"; + +// ─── CSV parsing ────────────────────────────────────────────────────────────── + +function parseCSVText(text: string): string[][] { + const rows: string[][] = []; + for (const rawLine of text.split(/\r?\n/)) { + if (!rawLine.trim()) continue; + const fields: string[] = []; + let inQuotes = false; + let field = ""; + for (let i = 0; i < rawLine.length; i++) { + const ch = rawLine[i]; + if (ch === '"') { + if (inQuotes && rawLine[i + 1] === '"') { field += '"'; i++; } + else inQuotes = !inQuotes; + } else if (ch === ',' && !inQuotes) { + fields.push(field.trim()); + field = ""; + } else { + field += ch; + } + } + fields.push(field.trim()); + rows.push(fields); + } + return rows; +} + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface ParsedRow { + rowNum: number; + name: string; + category: string; // raw name from CSV; empty = none + division: "B" | "C" | null; + eventType: "standard" | "trial"; + building: string; + room: string; + floor: string; + volunteersNeeded: number; + blockLabels: string[]; // known labels only (unknowns already filtered out) +} + +interface ParseResult { + valid: ParsedRow[]; + errors: Array<{ rowNum: number; message: string }>; + newCategories: string[]; // unrecognized → will auto-create + unknownBlocks: string[]; // unrecognized → will skip +} + +interface Props { + tournamentId: number; + categories: TournamentCategory[]; + timeBlocks: TimeBlock[]; + onImportComplete: () => Promise; +} + +// ─── Template + help content ────────────────────────────────────────────────── + +const CSV_TEMPLATE = [ + "name,category,division,type,building,room,floor,volunteers_needed,blocks", + '"Sample Event A",Science,B,standard,Main Building,101,1,3,"Morning A;Afternoon B"', + '"Sample Event B",,C,trial,,,,2,', +].join("\r\n"); + +const HELP_COLUMNS = [ + { col: "name", req: true, desc: "Event name. Required." }, + { col: "category", req: false, desc: "Category name. Unrecognized values are auto-created as custom categories." }, + { col: "division", req: false, desc: "B, C, or leave empty." }, + { col: "type", req: false, desc: "standard or trial. Defaults to standard." }, + { col: "building", req: false, desc: "Building name. Free text." }, + { col: "room", req: false, desc: "Room identifier. Free text." }, + { col: "floor", req: false, desc: "Floor identifier. Free text." }, + { col: "volunteers_needed", req: false, desc: "Integer ≥ 1. Defaults to 2." }, + { col: "blocks", req: false, desc: 'Semicolon-separated block labels, e.g. "Morning A;Afternoon". Labels must match existing time blocks; unrecognized labels are skipped.' }, +]; + +// ─── Parse CSV into structured result ───────────────────────────────────────── + +function parseImportCSV( + text: string, + categories: TournamentCategory[], + timeBlocks: TimeBlock[], +): ParseResult { + const rows = parseCSVText(text); + if (rows.length === 0) { + return { valid: [], errors: [], newCategories: [], unknownBlocks: [] }; + } + + const headers = rows[0].map((h) => h.toLowerCase().trim()); + const col = (name: string) => headers.indexOf(name); + const nameIdx = col("name"); + const catIdx = col("category"); + const divIdx = col("division"); + const typeIdx = col("type"); + const bldIdx = col("building"); + const roomIdx = col("room"); + const floorIdx = col("floor"); + const volIdx = col("volunteers_needed"); + const blocksIdx = col("blocks"); + + const knownCatNames = new Set(categories.map((c) => c.name.toLowerCase())); + const knownBlockLabels = new Set(timeBlocks.map((b) => b.label.toLowerCase())); + + const valid: ParsedRow[] = []; + const errors: Array<{ rowNum: number; message: string }> = []; + const newCategoriesSet = new Set(); + const unknownBlocksSet = new Set(); + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i; // 1-based data row (header = row 0) + const get = (idx: number) => (idx >= 0 && idx < row.length ? row[idx] : ""); + + const name = get(nameIdx).trim(); + if (!name) { + errors.push({ rowNum, message: "Missing required field: name" }); + continue; + } + + const rawType = get(typeIdx).trim().toLowerCase(); + if (rawType && rawType !== "standard" && rawType !== "trial") { + errors.push({ rowNum, message: `Invalid type "${get(typeIdx)}". Must be "standard" or "trial".` }); + continue; + } + + const rawVol = get(volIdx).trim(); + let volunteersNeeded = 2; + if (rawVol !== "") { + const n = parseInt(rawVol, 10); + if (isNaN(n) || n < 1 || String(n) !== rawVol) { + errors.push({ rowNum, message: `Invalid volunteers_needed "${rawVol}". Must be an integer ≥ 1.` }); + continue; + } + volunteersNeeded = n; + } + + const rawDiv = get(divIdx).trim().toUpperCase(); + let division: "B" | "C" | null = null; + if (rawDiv === "B") division = "B"; + else if (rawDiv === "C") division = "C"; + else if (rawDiv !== "") { + errors.push({ rowNum, message: `Invalid division "${get(divIdx)}". Must be "B", "C", or empty.` }); + continue; + } + + const categoryName = get(catIdx).trim(); + if (categoryName && !knownCatNames.has(categoryName.toLowerCase())) { + newCategoriesSet.add(categoryName); + } + + const rawBlocks = get(blocksIdx).trim(); + const allLabels = rawBlocks + ? rawBlocks.split(";").map((l) => l.trim()).filter(Boolean) + : []; + for (const label of allLabels) { + if (!knownBlockLabels.has(label.toLowerCase())) { + unknownBlocksSet.add(label); + } + } + const knownLabels = allLabels.filter((l) => knownBlockLabels.has(l.toLowerCase())); + + valid.push({ + rowNum, + name, + category: categoryName, + division, + eventType: rawType === "trial" ? "trial" : "standard", + building: get(bldIdx).trim(), + room: get(roomIdx).trim(), + floor: get(floorIdx).trim(), + volunteersNeeded, + blockLabels: knownLabels, + }); + } + + return { + valid, + errors, + newCategories: [...newCategoriesSet], + unknownBlocks: [...unknownBlocksSet], + }; +} + +// ─── CsvImportBar ───────────────────────────────────────────────────────────── + +export function CsvImportBar({ tournamentId, categories, timeBlocks, onImportComplete }: Props) { + const fileInputRef = useRef(null); + const [showHelp, setShowHelp] = useState(false); + const [preview, setPreview] = useState(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); + const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null); + + // ── File handling ──────────────────────────────────────────────────────── + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result as string; + const result = parseImportCSV(text, categories, timeBlocks); + setPreview(result); + setImportError(null); + }; + reader.readAsText(file); + e.target.value = ""; // reset so the same file can be re-selected + }; + + // ── Template download ──────────────────────────────────────────────────── + + const handleDownloadTemplate = () => { + const blob = new Blob([CSV_TEMPLATE], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "events-import-template.csv"; + a.click(); + URL.revokeObjectURL(url); + }; + + // ── Import execution ───────────────────────────────────────────────────── + + const handleImport = async () => { + if (!preview || preview.valid.length === 0) return; + setImporting(true); + setImportError(null); + setImportProgress({ done: 0, total: preview.valid.length }); + + try { + // Build category name → id map, creating new categories first + const catMap = new Map( + categories.map((c) => [c.name.toLowerCase(), c.id]) + ); + for (const catName of preview.newCategories) { + const created = await categoriesApi.create(tournamentId, catName); + catMap.set(catName.toLowerCase(), created.id); + } + + // Block label → id map + const blockMap = new Map( + timeBlocks.map((b) => [b.label.toLowerCase(), b.id]) + ); + + // Create events sequentially + for (let i = 0; i < preview.valid.length; i++) { + const row = preview.valid[i]; + const body: EventCreate = { + name: row.name, + division: row.division, + event_type: row.eventType, + category_id: row.category + ? (catMap.get(row.category.toLowerCase()) ?? null) + : null, + building: row.building || null, + room: row.room || null, + floor: row.floor || null, + volunteers_needed: row.volunteersNeeded, + time_block_ids: row.blockLabels + .map((l) => blockMap.get(l.toLowerCase())) + .filter((id): id is number => id !== undefined), + }; + await eventsApi.create(tournamentId, body); + setImportProgress({ done: i + 1, total: preview.valid.length }); + } + + setPreview(null); + await onImportComplete(); + } catch (e) { + setImportError(e instanceof Error ? e.message : "Import failed. Please try again."); + } finally { + setImporting(false); + setImportProgress(null); + } + }; + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( + <> +
+ + Import + + + {/* Upload CSV */} + + + {/* Download template */} + + + {/* Help popover */} +
+ + + {showHelp && ( +
e.stopPropagation()} + > +
+ + CSV Import Guide + + +
+ + + + + + + + + + + {HELP_COLUMNS.map((c) => ( + + + + + + ))} + +
ColumnReqDescription
+ {c.col} + + {c.req && ( + + )} + + {c.desc} +
+ +

+ Headers are case-insensitive and order-independent. Download the template for an example. +

+
+ )} +
+ +
+ + {/* Google Sheets — disabled until Sheets feature lands */} + +
+ + {/* Help backdrop — closes popover on outside click */} + {showHelp && ( +
setShowHelp(false)} + /> + )} + + {/* Import preview modal */} + {preview && ( + { setPreview(null); setImportError(null); }} + /> + )} + + ); +} + +// ─── Import preview modal ───────────────────────────────────────────────────── + +interface PreviewModalProps { + result: ParseResult; + importing: boolean; + progress: { done: number; total: number } | null; + error: string | null; + onImport: () => void; + onCancel: () => void; +} + +function ImportPreviewModal({ + result, + importing, + progress, + error, + onImport, + onCancel, +}: PreviewModalProps) { + const { valid, errors, newCategories, unknownBlocks } = result; + const hasWarnings = newCategories.length > 0 || unknownBlocks.length > 0; + + return ( + + {/* Summary line */} +
+

0 ? "4px" : 0, + }} + > + {valid.length === 0 + ? "No valid events found in the file." + : `${valid.length} event${valid.length !== 1 ? "s" : ""} ready to import.`} +

+ {errors.length > 0 && ( +

+ {errors.length} row{errors.length !== 1 ? "s" : ""} with errors will be skipped. +

+ )} +
+ + {/* Warnings — new categories / unknown blocks */} + {hasWarnings && ( +
+ +
+ {newCategories.length > 0 && ( +

0 ? "6px" : 0 }}> + New categories will be created:{" "} + {newCategories.join(", ")} +

+ )} + {unknownBlocks.length > 0 && ( +

+ Unknown block labels will be skipped:{" "} + {unknownBlocks.join(", ")} +

+ )} +
+
+ )} + + {/* Validation error list */} + {errors.length > 0 && ( +
+
+ Row errors +
+
+ {errors.map((err) => ( +
+ + Row {err.rowNum} + + {err.message} +
+ ))} +
+
+ )} + + {/* Progress */} + {progress && ( +

+ Importing… {progress.done} / {progress.total} +

+ )} + + {/* API error */} + {error && ( +

+ {error} +

+ )} + + {/* Actions */} +
+ + +
+
+ ); +} From 450f5e22ff7c35bec9cd7ed700263ae3d895ffe0 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:21:15 -0700 Subject: [PATCH 055/100] fix: use --color-surface for CSV import guide popover background --- frontend/components/events/CsvImportBar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx index 1515c3ec..57211839 100644 --- a/frontend/components/events/CsvImportBar.tsx +++ b/frontend/components/events/CsvImportBar.tsx @@ -400,7 +400,7 @@ export function CsvImportBar({ tournamentId, categories, timeBlocks, onImportCom left: 0, zIndex: 100, width: "400px", - background: "var(--color-surface-raised)", + background: "var(--color-surface)", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", boxShadow: "var(--shadow-lg)", From 9f84a201723f43075273570b35fb56771c592856 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:37:16 -0700 Subject: [PATCH 056/100] feat: add parseApiError helper and wire into events error handling - add body field to ApiError to capture full response JSON (needed for conflict key on 409 overlap) - create lib/errors.ts with general-purpose parseApiError covering 409 overlap, 409 conflict, 422 FastAPI array shape, 422 sheets shape, and 5xx/network fallback - replace raw error strings in TimeBlocksTable inline row save handlers - replace raw error string in EventSidePanel save handler --- frontend/components/events/EventSidePanel.tsx | 3 +- .../components/events/TimeBlocksTable.tsx | 5 +- frontend/lib/api.ts | 12 ++- frontend/lib/errors.ts | 80 +++++++++++++++++++ 4 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/errors.ts diff --git a/frontend/components/events/EventSidePanel.tsx b/frontend/components/events/EventSidePanel.tsx index 0bdfb14f..de93a48e 100644 --- a/frontend/components/events/EventSidePanel.tsx +++ b/frontend/components/events/EventSidePanel.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Event, EventCreate, TimeBlock, TournamentCategory } from "@/lib/api"; import { fmtTime, fmtDateShort } from "@/lib/formatters"; +import { parseApiError } from "@/lib/errors"; import { Button } from "@/components/ui/Button"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -455,7 +456,7 @@ export function EventSidePanel({ triggerClose(); } } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save event"); + setError(parseApiError(e)); } finally { setSaving(false); } diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx index 281b1e46..dd58f619 100644 --- a/frontend/components/events/TimeBlocksTable.tsx +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef } from "react"; import { TimeBlock, TimeBlockCreate, Event } from "@/lib/api"; import { fmtTime, fmtDate } from "@/lib/formatters"; +import { parseApiError } from "@/lib/errors"; import { Button } from "@/components/ui/Button"; import { IconPlus, IconEdit, IconTrash } from "@/components/ui/Icons"; @@ -249,7 +250,7 @@ export function TimeBlocksTable({ await onAdd(data); setShowAddRow(false); } catch (e) { - setSaveError(e instanceof Error ? e.message : "Failed to save block"); + setSaveError(parseApiError(e)); } finally { setSaving(false); } @@ -262,7 +263,7 @@ export function TimeBlocksTable({ await onEdit(id, data); setEditingId(null); } catch (e) { - setSaveError(e instanceof Error ? e.message : "Failed to save block"); + setSaveError(parseApiError(e)); } finally { setSaving(false); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index d21ab9ef..b4ab73d6 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -13,10 +13,13 @@ interface RequestOptions { export class ApiError extends Error { /** The raw `detail` value from the response body (may be a string or object). */ detail: unknown - constructor(public status: number, message: string, detail?: unknown) { + /** The full parsed response body — use this to access keys outside `detail` (e.g. `conflict`). */ + body: unknown + constructor(public status: number, message: string, detail?: unknown, body?: unknown) { super(message) this.name = 'ApiError' this.detail = detail + this.body = body } } @@ -35,12 +38,13 @@ async function request(path: string, options: RequestOptions = {}): Promise).detail ?? detail } catch {} const message = typeof detail === 'string' ? detail : `HTTP ${res.status}` - throw new ApiError(res.status, message, detail) + throw new ApiError(res.status, message, detail, body) } if (res.status === 204) return undefined as T diff --git a/frontend/lib/errors.ts b/frontend/lib/errors.ts new file mode 100644 index 00000000..adf5e985 --- /dev/null +++ b/frontend/lib/errors.ts @@ -0,0 +1,80 @@ +import { ApiError } from "@/lib/api"; +import { fmtTime } from "@/lib/formatters"; + +/** + * Converts any thrown value into a human-readable error string. + * + * Handles: + * - 409 overlap (time block create/edit) + * → "Overlaps with [label] ([start]–[end]). Adjust the time range." + * - 409 other conflict + * → the detail message or a generic conflict string + * - 422 validation — FastAPI array shape: [{ loc, msg, type }] + * → first item's msg, stripped of "Value error, " prefix + * - 422 validation — sheets shape: { errors: [{ message }], warnings: [...] } + * → first error's message, or a count summary + * - 5xx / network + * → "Something went wrong. Please try again." + * - anything else + * → the error's own message or the generic fallback + */ +export function parseApiError(e: unknown): string { + if (!(e instanceof ApiError)) { + return e instanceof Error + ? e.message + : "Something went wrong. Please try again."; + } + + // ── 409 Conflict ──────────────────────────────────────────────────────────── + if (e.status === 409) { + const body = e.body as Record | undefined; + const conflict = body?.conflict as + | { label?: string; start?: string; end?: string } + | undefined; + + if (conflict) { + // Time block overlap: backend returns { detail: "...", conflict: { label, start, end } } + const label = conflict.label ?? "another block"; + const range = + conflict.start && conflict.end + ? ` (${fmtTime(conflict.start)}–${fmtTime(conflict.end)})` + : ""; + return `Overlaps with ${label}${range}. Adjust the time range.`; + } + + // Other 409 (e.g. affected_events is handled upstream by DeleteBlockModal) + return e.message || "Conflict. Please try again."; + } + + // ── 422 Validation ────────────────────────────────────────────────────────── + if (e.status === 422) { + const detail = e.detail; + + // FastAPI array shape: [{ loc: [...], msg: "...", type: "..." }] + if (Array.isArray(detail) && detail.length > 0) { + const first = detail[0] as Record; + if (typeof first.msg === "string") { + return first.msg.replace(/^Value error,\s*/i, ""); + } + } + + // Sheets shape: { errors: [{ message: "..." }], warnings: [...] } + if (detail && typeof detail === "object" && "errors" in detail) { + const errs = (detail as { errors?: { message?: string }[] }).errors ?? []; + if (errs.length > 0 && errs[0].message) { + return errs.length === 1 + ? errs[0].message + : `${errs.length} validation errors. First: ${errs[0].message}`; + } + } + + return "Validation error. Please check your input."; + } + + // ── 5xx / network ─────────────────────────────────────────────────────────── + if (e.status >= 500) { + return "Something went wrong. Please try again."; + } + + return e.message || "Something went wrong. Please try again."; +} From cc92833227badce00c64aef1f71dfff21aadd5a3 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:38:18 -0700 Subject: [PATCH 057/100] fix: closed gap between unscheduled sticky subheader and the main header --- frontend/components/events/EventTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx index e42b7d4b..83f4e99a 100644 --- a/frontend/components/events/EventTimeline.tsx +++ b/frontend/components/events/EventTimeline.tsx @@ -15,8 +15,8 @@ const ROW_H = 44; // px — event row height const DATE_ROW_H = 26; // px — top header row const BLOCK_ROW_H = 32; // px — bottom header row const CONTROLS_H = 50; // px — controls bar height (incl. padding) -// Total height of the sticky header (controls + 1px border + date row + block row) -const STICKY_H = CONTROLS_H + 1 + DATE_ROW_H + BLOCK_ROW_H; +// Total height of the sticky header (controls + 1px border + date row + block row - 22px padding from main div) +const STICKY_H = CONTROLS_H + 1 + DATE_ROW_H + BLOCK_ROW_H - 22; type ColorBy = "category" | "division" | "type"; type GroupBy = "category" | "building" | "az"; From 8d98ed86ef384934f6477907144dde18b63d5813 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:47:11 -0700 Subject: [PATCH 058/100] feat: consolidate import/export into CsvImportBar with Import/Export dropdowns - replace Upload CSV button with Import dropdown (Events CSV, Time Blocks CSV, template downloads) - add Export dropdown (Events, Time Blocks) with client-side CSV generation - add time blocks CSV import with parser validating label/date/start/end fields - shared ImportPreviewModal handles both events and time blocks previews - help popover now covers both formats in separate sections - pass events prop to CsvImportBar for export --- .../dashboard/[tournamentId]/events/page.tsx | 1 + frontend/components/events/CsvImportBar.tsx | 1013 ++++++++++------- 2 files changed, 597 insertions(+), 417 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 0d580290..92baee7c 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -237,6 +237,7 @@ export default function EventsPage() { loadAll(true)} diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx index 57211839..bb221b28 100644 --- a/frontend/components/events/CsvImportBar.tsx +++ b/frontend/components/events/CsvImportBar.tsx @@ -3,14 +3,14 @@ import { useRef, useState } from "react"; import { Modal } from "@/components/ui/Modal"; import { Button } from "@/components/ui/Button"; -import { IconUpload, IconSheets, IconWarning } from "@/components/ui/Icons"; import { - eventsApi, - categoriesApi, - EventCreate, - TimeBlock, - TournamentCategory, + IconUpload, IconExport, IconSheets, IconWarning, IconChevronDown, +} from "@/components/ui/Icons"; +import { + eventsApi, timeBlocksApi, categoriesApi, + EventCreate, TimeBlock, TournamentCategory, Event, } from "@/lib/api"; +import { parseApiError } from "@/lib/errors"; // ─── CSV parsing ────────────────────────────────────────────────────────────── @@ -39,62 +39,33 @@ function parseCSVText(text: string): string[][] { return rows; } -// ─── Types ──────────────────────────────────────────────────────────────────── +// ─── Events import types + parser ──────────────────────────────────────────── -interface ParsedRow { +interface ParsedEventRow { rowNum: number; name: string; - category: string; // raw name from CSV; empty = none + category: string; division: "B" | "C" | null; eventType: "standard" | "trial"; building: string; room: string; floor: string; volunteersNeeded: number; - blockLabels: string[]; // known labels only (unknowns already filtered out) -} - -interface ParseResult { - valid: ParsedRow[]; - errors: Array<{ rowNum: number; message: string }>; - newCategories: string[]; // unrecognized → will auto-create - unknownBlocks: string[]; // unrecognized → will skip + blockLabels: string[]; } -interface Props { - tournamentId: number; - categories: TournamentCategory[]; - timeBlocks: TimeBlock[]; - onImportComplete: () => Promise; +interface EventParseResult { + valid: ParsedEventRow[]; + errors: Array<{ rowNum: number; message: string }>; + newCategories: string[]; + unknownBlocks: string[]; } -// ─── Template + help content ────────────────────────────────────────────────── - -const CSV_TEMPLATE = [ - "name,category,division,type,building,room,floor,volunteers_needed,blocks", - '"Sample Event A",Science,B,standard,Main Building,101,1,3,"Morning A;Afternoon B"', - '"Sample Event B",,C,trial,,,,2,', -].join("\r\n"); - -const HELP_COLUMNS = [ - { col: "name", req: true, desc: "Event name. Required." }, - { col: "category", req: false, desc: "Category name. Unrecognized values are auto-created as custom categories." }, - { col: "division", req: false, desc: "B, C, or leave empty." }, - { col: "type", req: false, desc: "standard or trial. Defaults to standard." }, - { col: "building", req: false, desc: "Building name. Free text." }, - { col: "room", req: false, desc: "Room identifier. Free text." }, - { col: "floor", req: false, desc: "Floor identifier. Free text." }, - { col: "volunteers_needed", req: false, desc: "Integer ≥ 1. Defaults to 2." }, - { col: "blocks", req: false, desc: 'Semicolon-separated block labels, e.g. "Morning A;Afternoon". Labels must match existing time blocks; unrecognized labels are skipped.' }, -]; - -// ─── Parse CSV into structured result ───────────────────────────────────────── - -function parseImportCSV( +function parseEventsCSV( text: string, categories: TournamentCategory[], timeBlocks: TimeBlock[], -): ParseResult { +): EventParseResult { const rows = parseCSVText(text); if (rows.length === 0) { return { valid: [], errors: [], newCategories: [], unknownBlocks: [] }; @@ -115,14 +86,14 @@ function parseImportCSV( const knownCatNames = new Set(categories.map((c) => c.name.toLowerCase())); const knownBlockLabels = new Set(timeBlocks.map((b) => b.label.toLowerCase())); - const valid: ParsedRow[] = []; - const errors: Array<{ rowNum: number; message: string }> = []; + const valid: ParsedEventRow[] = []; + const errors: Array<{ rowNum: number; message: string }> = []; const newCategoriesSet = new Set(); const unknownBlocksSet = new Set(); for (let i = 1; i < rows.length; i++) { const row = rows[i]; - const rowNum = i; // 1-based data row (header = row 0) + const rowNum = i; const get = (idx: number) => (idx >= 0 && idx < row.length ? row[idx] : ""); const name = get(nameIdx).trim(); @@ -167,11 +138,8 @@ function parseImportCSV( ? rawBlocks.split(";").map((l) => l.trim()).filter(Boolean) : []; for (const label of allLabels) { - if (!knownBlockLabels.has(label.toLowerCase())) { - unknownBlocksSet.add(label); - } + if (!knownBlockLabels.has(label.toLowerCase())) unknownBlocksSet.add(label); } - const knownLabels = allLabels.filter((l) => knownBlockLabels.has(l.toLowerCase())); valid.push({ rowNum, @@ -183,7 +151,7 @@ function parseImportCSV( room: get(roomIdx).trim(), floor: get(floorIdx).trim(), volunteersNeeded, - blockLabels: knownLabels, + blockLabels: allLabels.filter((l) => knownBlockLabels.has(l.toLowerCase())), }); } @@ -195,77 +163,368 @@ function parseImportCSV( }; } +// ─── Time blocks import types + parser ─────────────────────────────────────── + +interface ParsedTimeBlockRow { + rowNum: number; + label: string; + date: string; + start: string; + end: string; +} + +interface TimeBlockParseResult { + valid: ParsedTimeBlockRow[]; + errors: Array<{ rowNum: number; message: string }>; +} + +function parseTimeBlocksCSV(text: string): TimeBlockParseResult { + const rows = parseCSVText(text); + if (rows.length === 0) return { valid: [], errors: [] }; + + const headers = rows[0].map((h) => h.toLowerCase().trim()); + const col = (name: string) => headers.indexOf(name); + const labelIdx = col("label"); + const dateIdx = col("date"); + const startIdx = col("start"); + const endIdx = col("end"); + + const valid: ParsedTimeBlockRow[] = []; + const errors: Array<{ rowNum: number; message: string }> = []; + + for (let i = 1; i < rows.length; i++) { + const row = rows[i]; + const rowNum = i; + const get = (idx: number) => (idx >= 0 && idx < row.length ? row[idx] : ""); + + const label = get(labelIdx).trim(); + const date = get(dateIdx).trim(); + const start = get(startIdx).trim(); + const end = get(endIdx).trim(); + + if (!label) { errors.push({ rowNum, message: "Missing required field: label" }); continue; } + if (!date) { errors.push({ rowNum, message: "Missing required field: date" }); continue; } + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + errors.push({ rowNum, message: `Invalid date "${date}". Must be YYYY-MM-DD (e.g. 2025-03-15).` }); + continue; + } + if (!start) { errors.push({ rowNum, message: "Missing required field: start" }); continue; } + if (!/^\d{2}:\d{2}$/.test(start)) { + errors.push({ rowNum, message: `Invalid start time "${start}". Must be HH:MM (e.g. 09:00).` }); + continue; + } + if (!end) { errors.push({ rowNum, message: "Missing required field: end" }); continue; } + if (!/^\d{2}:\d{2}$/.test(end)) { + errors.push({ rowNum, message: `Invalid end time "${end}". Must be HH:MM (e.g. 11:00).` }); + continue; + } + + valid.push({ rowNum, label, date, start, end }); + } + + return { valid, errors }; +} + +// ─── Export helpers ─────────────────────────────────────────────────────────── + +function escapeCSVField(v: string | number | null | undefined): string { + if (v === null || v === undefined) return ""; + const s = String(v); + return s.includes(",") || s.includes('"') || s.includes("\n") + ? `"${s.replace(/"/g, '""')}"` + : s; +} + +function downloadCSV(content: string, filename: string) { + const blob = new Blob([content], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +function todayStr(): string { + const d = new Date(); + return [ + d.getFullYear(), + String(d.getMonth() + 1).padStart(2, "0"), + String(d.getDate()).padStart(2, "0"), + ].join("-"); +} + +function buildEventsCSV( + events: Event[], + categories: TournamentCategory[], + timeBlocks: TimeBlock[], +): string { + const catMap = new Map(categories.map((c) => [c.id, c.name])); + const blockMap = new Map(timeBlocks.map((b) => [b.id, b.label])); + const rows = [ + ["name", "category", "division", "type", "building", "room", "floor", "volunteers_needed", "blocks"], + ...events.map((e) => [ + e.name, + catMap.get(e.category_id ?? -1) ?? "", + e.division ?? "", + e.event_type, + e.building ?? "", + e.room ?? "", + e.floor ?? "", + String(e.volunteers_needed), + (e.time_block_ids ?? []).map((id) => blockMap.get(id) ?? "").filter(Boolean).join(";"), + ]), + ]; + return rows.map((r) => r.map(escapeCSVField).join(",")).join("\r\n"); +} + +function buildTimeBlocksCSV(timeBlocks: TimeBlock[], events: Event[]): string { + const rows = [ + ["label", "date", "start", "end", "event_count"], + ...timeBlocks.map((b) => [ + b.label, + b.date, + b.start, + b.end, + String(events.filter((e) => (e.time_block_ids ?? []).includes(b.id)).length), + ]), + ]; + return rows.map((r) => r.map(escapeCSVField).join(",")).join("\r\n"); +} + +// ─── Templates ──────────────────────────────────────────────────────────────── + +const EVENTS_CSV_TEMPLATE = [ + "name,category,division,type,building,room,floor,volunteers_needed,blocks", + '"Sample Event A",Science,B,standard,Main Building,101,1,3,"Morning A;Afternoon B"', + '"Sample Event B",,C,trial,,,,2,', +].join("\r\n"); + +const TIME_BLOCKS_CSV_TEMPLATE = [ + "label,date,start,end", + "Morning A,2025-03-15,09:00,11:00", + "Afternoon B,2025-03-15,13:00,15:30", +].join("\r\n"); + +// ─── Help content ───────────────────────────────────────────────────────────── + +const EVENTS_HELP_COLUMNS = [ + { col: "name", req: true, desc: "Event name. Required." }, + { col: "category", req: false, desc: "Category name. Unrecognized values are auto-created as custom categories." }, + { col: "division", req: false, desc: "B, C, or leave empty." }, + { col: "type", req: false, desc: "standard or trial. Defaults to standard." }, + { col: "building", req: false, desc: "Building name. Free text." }, + { col: "room", req: false, desc: "Room identifier. Free text." }, + { col: "floor", req: false, desc: "Floor identifier. Free text." }, + { col: "volunteers_needed",req: false, desc: "Integer ≥ 1. Defaults to 2." }, + { col: "blocks", req: false, desc: 'Semicolon-separated block labels. Unrecognized labels are skipped.' }, +]; + +const TIME_BLOCKS_HELP_COLUMNS = [ + { col: "label", req: true, desc: "Block label (e.g. Morning A). Required." }, + { col: "date", req: true, desc: "Date in YYYY-MM-DD format (e.g. 2025-03-15). Required." }, + { col: "start", req: true, desc: "Start time in HH:MM 24-hour format (e.g. 09:00). Required." }, + { col: "end", req: true, desc: "End time in HH:MM 24-hour format (e.g. 11:00). Required." }, +]; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + tournamentId: number; + events: Event[]; + categories: TournamentCategory[]; + timeBlocks: TimeBlock[]; + onImportComplete: () => Promise; +} + +// ─── Dropdown menu ──────────────────────────────────────────────────────────── + +type MenuItem = { label: string; icon?: React.ReactNode; onClick: () => void } | "separator"; + +function DropdownMenu({ items }: { items: MenuItem[] }) { + return ( +
e.stopPropagation()} + > + {items.map((item, i) => + item === "separator" ? ( +
+ ) : ( + + ) + )} +
+ ); +} + +// ─── Error row list (shared between event + time block preview modals) ──────── + +function ErrorRowList({ errors }: { errors: Array<{ rowNum: number; message: string }> }) { + return ( +
+
+ Row errors +
+
+ {errors.map((err) => ( +
+ + Row {err.rowNum} + + {err.message} +
+ ))} +
+
+ ); +} + // ─── CsvImportBar ───────────────────────────────────────────────────────────── -export function CsvImportBar({ tournamentId, categories, timeBlocks, onImportComplete }: Props) { - const fileInputRef = useRef(null); - const [showHelp, setShowHelp] = useState(false); - const [preview, setPreview] = useState(null); - const [importing, setImporting] = useState(false); - const [importError, setImportError] = useState(null); +export function CsvImportBar({ + tournamentId, + events, + categories, + timeBlocks, + onImportComplete, +}: Props) { + type OpenMenu = "import" | "export" | "help" | null; + const [openMenu, setOpenMenu] = useState(null); + + const eventsFileRef = useRef(null); + const timeBlocksFileRef = useRef(null); + + type PreviewState = + | { kind: "events"; result: EventParseResult } + | { kind: "timeblocks"; result: TimeBlockParseResult } + | null; + + const [preview, setPreview] = useState(null); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(null); const [importProgress, setImportProgress] = useState<{ done: number; total: number } | null>(null); + const toggle = (menu: Exclude) => + setOpenMenu((prev) => (prev === menu ? null : menu)); + // ── File handling ──────────────────────────────────────────────────────── - const handleFileChange = (e: React.ChangeEvent) => { + const handleEventsFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { - const text = ev.target?.result as string; - const result = parseImportCSV(text, categories, timeBlocks); - setPreview(result); + const result = parseEventsCSV(ev.target?.result as string, categories, timeBlocks); + setPreview({ kind: "events", result }); setImportError(null); }; reader.readAsText(file); - e.target.value = ""; // reset so the same file can be re-selected + e.target.value = ""; }; - // ── Template download ──────────────────────────────────────────────────── - - const handleDownloadTemplate = () => { - const blob = new Blob([CSV_TEMPLATE], { type: "text/csv" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "events-import-template.csv"; - a.click(); - URL.revokeObjectURL(url); + const handleTimeBlocksFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const result = parseTimeBlocksCSV(ev.target?.result as string); + setPreview({ kind: "timeblocks", result }); + setImportError(null); + }; + reader.readAsText(file); + e.target.value = ""; }; // ── Import execution ───────────────────────────────────────────────────── - const handleImport = async () => { - if (!preview || preview.valid.length === 0) return; + const handleImportEvents = async (result: EventParseResult) => { setImporting(true); setImportError(null); - setImportProgress({ done: 0, total: preview.valid.length }); - + setImportProgress({ done: 0, total: result.valid.length }); try { - // Build category name → id map, creating new categories first const catMap = new Map( categories.map((c) => [c.name.toLowerCase(), c.id]) ); - for (const catName of preview.newCategories) { + for (const catName of result.newCategories) { const created = await categoriesApi.create(tournamentId, catName); catMap.set(catName.toLowerCase(), created.id); } + const blockMap = new Map(timeBlocks.map((b) => [b.label.toLowerCase(), b.id])); - // Block label → id map - const blockMap = new Map( - timeBlocks.map((b) => [b.label.toLowerCase(), b.id]) - ); - - // Create events sequentially - for (let i = 0; i < preview.valid.length; i++) { - const row = preview.valid[i]; + for (let i = 0; i < result.valid.length; i++) { + const row = result.valid[i]; const body: EventCreate = { name: row.name, division: row.division, event_type: row.eventType, - category_id: row.category - ? (catMap.get(row.category.toLowerCase()) ?? null) - : null, + category_id: row.category ? (catMap.get(row.category.toLowerCase()) ?? null) : null, building: row.building || null, room: row.room || null, floor: row.floor || null, @@ -275,19 +534,124 @@ export function CsvImportBar({ tournamentId, categories, timeBlocks, onImportCom .filter((id): id is number => id !== undefined), }; await eventsApi.create(tournamentId, body); - setImportProgress({ done: i + 1, total: preview.valid.length }); + setImportProgress({ done: i + 1, total: result.valid.length }); } + setPreview(null); + await onImportComplete(); + } catch (err) { + setImportError(parseApiError(err)); + } finally { + setImporting(false); + setImportProgress(null); + } + }; + const handleImportTimeBlocks = async (result: TimeBlockParseResult) => { + setImporting(true); + setImportError(null); + setImportProgress({ done: 0, total: result.valid.length }); + try { + for (let i = 0; i < result.valid.length; i++) { + const row = result.valid[i]; + await timeBlocksApi.create(tournamentId, { + label: row.label, + date: row.date, + start: row.start, + end: row.end, + }); + setImportProgress({ done: i + 1, total: result.valid.length }); + } setPreview(null); await onImportComplete(); - } catch (e) { - setImportError(e instanceof Error ? e.message : "Import failed. Please try again."); + } catch (err) { + setImportError(parseApiError(err)); } finally { setImporting(false); setImportProgress(null); } }; + const handleImport = () => { + if (!preview) return; + if (preview.kind === "events") handleImportEvents(preview.result); + if (preview.kind === "timeblocks") handleImportTimeBlocks(preview.result); + }; + + // ── Export ─────────────────────────────────────────────────────────────── + + const handleExportEvents = () => { + downloadCSV(buildEventsCSV(events, categories, timeBlocks), `events-${tournamentId}-${todayStr()}.csv`); + setOpenMenu(null); + }; + + const handleExportTimeBlocks = () => { + downloadCSV(buildTimeBlocksCSV(timeBlocks, events), `time-blocks-${tournamentId}-${todayStr()}.csv`); + setOpenMenu(null); + }; + + // ── Import menu items ───────────────────────────────────────────────────── + + const importItems: MenuItem[] = [ + { + label: "Events CSV…", + icon: , + onClick: () => { setOpenMenu(null); eventsFileRef.current?.click(); }, + }, + { + label: "Time Blocks CSV…", + icon: , + onClick: () => { setOpenMenu(null); timeBlocksFileRef.current?.click(); }, + }, + "separator", + { + label: "↓ Events template", + onClick: () => { + downloadCSV(EVENTS_CSV_TEMPLATE, "events-import-template.csv"); + setOpenMenu(null); + }, + }, + { + label: "↓ Time Blocks template", + onClick: () => { + downloadCSV(TIME_BLOCKS_CSV_TEMPLATE, "time-blocks-import-template.csv"); + setOpenMenu(null); + }, + }, + ]; + + const exportItems: MenuItem[] = [ + { + label: "Events", + icon: , + onClick: handleExportEvents, + }, + { + label: "Time Blocks", + icon: , + onClick: handleExportTimeBlocks, + }, + ]; + + // ── Shared button style ────────────────────────────────────────────────── + + const menuBtnStyle = (active: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + gap: "5px", + fontFamily: "var(--font-sans)", + fontSize: "12px", + fontWeight: 500, + color: active ? "var(--color-text-primary)" : "var(--color-text-secondary)", + background: active ? "var(--color-accent-subtle)" : "none", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + padding: "4px 10px", + cursor: "pointer", + transition: "background var(--transition-fast)", + userSelect: "none", + position: "relative", + }); + // ── Render ─────────────────────────────────────────────────────────────── return ( @@ -302,104 +666,67 @@ export function CsvImportBar({ tournamentId, categories, timeBlocks, onImportCom border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", marginBottom: "20px", - position: "relative", }} > - - Import - - - {/* Upload CSV */} - + {/* ── Import ▼ ── */} +
+ + {openMenu === "import" && } +
- {/* Download template */} - + {/* ── Export ▼ ── */} +
+ + {openMenu === "export" && } +
- {/* Help popover */} + {/* ── Help popover ── */}
- {showHelp && ( + {openMenu === "help" && (
e.stopPropagation()} > -
- +
+ CSV Import Guide
- - - - - - - - - - {HELP_COLUMNS.map((c) => ( - - - - - - ))} - -
ColumnReqDescription
- {c.col} - - {c.req && ( - - )} - - {c.desc} -
- -

- Headers are case-insensitive and order-independent. Download the template for an example. + +

+ +
+ +

+ Headers are case-insensitive and order-independent.

)}
-
+
- {/* Google Sheets — disabled until Sheets feature lands */} + {/* ── Google Sheets — disabled ── */}
- {/* Help backdrop — closes popover on outside click */} - {showHelp && ( + {/* Backdrop — closes any open menu */} + {openMenu !== null && (
setShowHelp(false)} + style={{ position: "fixed", inset: 0, zIndex: 100 }} + onClick={() => setOpenMenu(null)} /> )} + {/* Hidden file inputs */} + + + {/* Import preview modal */} {preview && ( ; +}) { + return ( + <> +

+ {title} +

+ + + + + + + + + + {columns.map((c) => ( + + + + + + ))} + +
ColumnReqDescription
+ {c.col} + + {c.req && } + + {c.desc} +
+ + ); +} + // ─── Import preview modal ───────────────────────────────────────────────────── interface PreviewModalProps { - result: ParseResult; + preview: NonNullable<{ kind: "events"; result: EventParseResult } | { kind: "timeblocks"; result: TimeBlockParseResult }>; importing: boolean; progress: { done: number; total: number } | null; error: string | null; @@ -585,168 +866,66 @@ interface PreviewModalProps { onCancel: () => void; } -function ImportPreviewModal({ - result, - importing, - progress, - error, - onImport, - onCancel, -}: PreviewModalProps) { - const { valid, errors, newCategories, unknownBlocks } = result; - const hasWarnings = newCategories.length > 0 || unknownBlocks.length > 0; +function ImportPreviewModal({ preview, importing, progress, error, onImport, onCancel }: PreviewModalProps) { + const isEvents = preview.kind === "events"; + const valid = preview.result.valid; + const errors = preview.result.errors; + const noun = isEvents ? "event" : "block"; + + const newCategories = isEvents ? (preview.result as EventParseResult).newCategories : []; + const unknownBlocks = isEvents ? (preview.result as EventParseResult).unknownBlocks : []; + const hasWarnings = newCategories.length > 0 || unknownBlocks.length > 0; + + const title = isEvents ? "Import events — preview" : "Import time blocks — preview"; return ( - - {/* Summary line */} + + {/* Summary */}
-

0 ? "4px" : 0, - }} - > +

0 ? "4px" : 0 }}> {valid.length === 0 - ? "No valid events found in the file." - : `${valid.length} event${valid.length !== 1 ? "s" : ""} ready to import.`} + ? `No valid ${noun}s found in the file.` + : `${valid.length} ${noun}${valid.length !== 1 ? "s" : ""} ready to import.`}

{errors.length > 0 && ( -

+

{errors.length} row{errors.length !== 1 ? "s" : ""} with errors will be skipped.

)}
- {/* Warnings — new categories / unknown blocks */} + {/* Warnings (events only) */} {hasWarnings && ( -
- -
+
+ +
{newCategories.length > 0 && (

0 ? "6px" : 0 }}> - New categories will be created:{" "} - {newCategories.join(", ")} + New categories will be created: {newCategories.join(", ")}

)} {unknownBlocks.length > 0 && (

- Unknown block labels will be skipped:{" "} - {unknownBlocks.join(", ")} + Unknown block labels will be skipped: {unknownBlocks.join(", ")}

)}
)} - {/* Validation error list */} - {errors.length > 0 && ( -
-
- Row errors -
-
- {errors.map((err) => ( -
- - Row {err.rowNum} - - {err.message} -
- ))} -
-
- )} + {/* Row errors */} + {errors.length > 0 && } {/* Progress */} {progress && ( -

+

Importing… {progress.done} / {progress.total}

)} {/* API error */} {error && ( -

+

{error}

)} @@ -765,7 +944,7 @@ function ImportPreviewModal({ > {importing && progress ? `Importing ${progress.done}/${progress.total}…` - : `Import ${valid.length} event${valid.length !== 1 ? "s" : ""}`} + : `Import ${valid.length} ${noun}${valid.length !== 1 ? "s" : ""}`}
From 10d37b57d00fb595012ae1859b64c76ac774ff61 Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 15:57:35 -0700 Subject: [PATCH 059/100] refactor: update display text for elements in the import/export csv bar --- frontend/components/events/CsvImportBar.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx index bb221b28..c098eb92 100644 --- a/frontend/components/events/CsvImportBar.tsx +++ b/frontend/components/events/CsvImportBar.tsx @@ -593,25 +593,25 @@ export function CsvImportBar({ const importItems: MenuItem[] = [ { - label: "Events CSV…", + label: "Events", icon: , onClick: () => { setOpenMenu(null); eventsFileRef.current?.click(); }, }, { - label: "Time Blocks CSV…", + label: "Time Blocks", icon: , onClick: () => { setOpenMenu(null); timeBlocksFileRef.current?.click(); }, }, "separator", { - label: "↓ Events template", + label: "Events template", onClick: () => { downloadCSV(EVENTS_CSV_TEMPLATE, "events-import-template.csv"); setOpenMenu(null); }, }, { - label: "↓ Time Blocks template", + label: "Time Blocks template", onClick: () => { downloadCSV(TIME_BLOCKS_CSV_TEMPLATE, "time-blocks-import-template.csv"); setOpenMenu(null); @@ -668,27 +668,27 @@ export function CsvImportBar({ marginBottom: "20px", }} > - {/* ── Import ▼ ── */} + {/* ── Import ── */}
{openMenu === "import" && }
- {/* ── Export ▼ ── */} + {/* ── Export ── */}
{openMenu === "export" && } @@ -698,7 +698,7 @@ export function CsvImportBar({
@@ -687,7 +689,7 @@ export function CsvImportBar({ onClick={() => toggle("export")} style={menuBtnStyle(openMenu === "export")} > - + Export CSV diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 16025227..f9dfa194 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -311,4 +311,18 @@ export function IconLayoutTable({ size = 14, ...props }: IconProps) { ); +} + +/** Document with a folded corner and text lines — used for CSV template downloads. */ +export function IconTemplate({ size = 14, ...props }: IconProps) { + return ( + + {/* Page body — right side clipped for fold */} + + {/* Fold triangle */} + + {/* Text lines */} + + + ); } \ No newline at end of file From db7d42132ff5689d9d56fa26eb0d1358e88d62cc Mon Sep 17 00:00:00 2001 From: Ethan Date: Thu, 16 Apr 2026 16:05:40 -0700 Subject: [PATCH 061/100] fix: use tournament name in export CSV filenames - filenames now follow [tournament-slug]-events-[date].csv format - slug derived from selectedTournament.name via useTournament hook - falls back to tournament-[id] if tournament not yet loaded --- frontend/components/events/CsvImportBar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx index 4c987428..aa4baf68 100644 --- a/frontend/components/events/CsvImportBar.tsx +++ b/frontend/components/events/CsvImportBar.tsx @@ -11,6 +11,7 @@ import { EventCreate, TimeBlock, TournamentCategory, Event, } from "@/lib/api"; import { parseApiError } from "@/lib/errors"; +import { useTournament } from "@/lib/useTournament"; // ─── CSV parsing ────────────────────────────────────────────────────────────── @@ -455,6 +456,11 @@ export function CsvImportBar({ timeBlocks, onImportComplete, }: Props) { + const { selectedTournament } = useTournament(); + const tournamentSlug = selectedTournament + ? selectedTournament.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") + : `tournament-${tournamentId}`; + type OpenMenu = "import" | "export" | "help" | null; const [openMenu, setOpenMenu] = useState(null); @@ -580,12 +586,12 @@ export function CsvImportBar({ // ── Export ─────────────────────────────────────────────────────────────── const handleExportEvents = () => { - downloadCSV(buildEventsCSV(events, categories, timeBlocks), `events-${tournamentId}-${todayStr()}.csv`); + downloadCSV(buildEventsCSV(events, categories, timeBlocks), `${tournamentSlug}-events-${todayStr()}.csv`); setOpenMenu(null); }; const handleExportTimeBlocks = () => { - downloadCSV(buildTimeBlocksCSV(timeBlocks, events), `time-blocks-${tournamentId}-${todayStr()}.csv`); + downloadCSV(buildTimeBlocksCSV(timeBlocks, events), `${tournamentSlug}-time-blocks-${todayStr()}.csv`); setOpenMenu(null); }; From 6f966475d604d017cbb67374df4d8df9071ea513 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 17 Apr 2026 19:14:01 -0700 Subject: [PATCH 062/100] feat: update favicon --- frontend/public/apple-touch-icon.png | Bin 5603 -> 0 bytes frontend/public/favicon-192.png | Bin 3947 -> 0 bytes frontend/public/favicon-512.png | Bin 13269 -> 0 bytes frontend/public/favicon.ico | Bin 316 -> 0 bytes frontend/public/favicon.svg | 21 ++++++--------------- 5 files changed, 6 insertions(+), 15 deletions(-) delete mode 100644 frontend/public/apple-touch-icon.png delete mode 100644 frontend/public/favicon-192.png delete mode 100644 frontend/public/favicon-512.png delete mode 100644 frontend/public/favicon.ico diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png deleted file mode 100644 index 52ba921d0945602e9788d405c5e42811c7967d23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5603 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 diff --git a/frontend/public/favicon-512.png b/frontend/public/favicon-512.png deleted file mode 100644 index 8bffa18fcfdd03b48a8697a8cca8c84125a22110..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13269 zcmch8cT`i`_U;BmIaokNL=><9B8mcvNT^l_C{2oVH42)5NC{1v6;VJ5RXWm1yXq{8W2*M4Q+z8)l_?q$RScI?Dm-VzyAuRTPDV13f2(lAFpE_>hl`!7t9cw-7 zH_U9EbT>Dp&`uN0)~@2d_q3cB+cY4!?pkd@=$j2C3aj^ql@{EsuQQ0gCBdxIx38m= zaS4=_H5~hPcE_!=ceb9D{^N_ig2#?O{#d(XtUzo1HK*GJ!t>83H6^=mX^^mx)}_q#6vmf+jir|cVo_GrPmUK zb*96X?a;cqSdXV$5d9;&b0n`@8`~O_MGni*o~EB9ciU~lY`B0Eh|D#yu0dXtFQEc!B%agjc~e~L=9ejYJ}=VgQZ`aK zZ=58Nhk16oKG`O+>hdZoWv8|CRcq7?k)iC2jEt-c2dbzd41cskMGFt2aO4`%@4X1) z$+NRV8(oYpot8na>SVQ>`y|cV;>Mc^E%?MW$jx3oLKT*{EYamS7ejh??+@N@M}!Yr zzuFno*e6T-PB`cWLh@;ZbO{SwbwaZ}>$LG3e2R}Y*D>_@^X({1lT%%6yp&QIyf3ye zCa*Xks@cB!d0Okj%W2gyVlY8_G&{0DBlFUMqb?K}98-o4rs!~GQ2&iX9E zsJ~ndbwk2Otd{%vaU9yzm0{5rh-X&?=xSekK7rZc{WwYp=W3pI@F%Ss}{U97j zCX-ETFHf-|-0#v4Ac5r$#9}I8hW1L$Q07r9$sntprddq>x|eo>atT3FFNP@OAFlJ2 z4A)sV$XG}&+hBqVM{z&Z&!Xrqs$$lu`XgGkAqrOf_up!}mGvH^SYV|#X(Qb|Jw58J z%;yS2`JoDrZHBp!dtVh>85(V0zQpbm;@1qnVzVZ<y)fM|t^V2+AnJkr}~^W`nZ=Tg{5&cW=`$4GnCq^8V1V*x7!d4I;ZO)mJzJ*>f=7sZH5pe+TzPDR~Pd(q0+^RW>`f$XYcW0u0mW1#y=z41E!4<_W+e2AA z)FO%cutkDsrZ5hf@~~KEhv~O zJCXYH1kv2&@p&Z2IA;crrimWdjs(gaCQLXvIoX)fx0qHUN0gPRyJ#=5hqfYt$K>mZ zgdf+gG5IdgW~$Enm%FPET+&34rut{Wr!E`GRiAi;I%0F@%HlU~3APrK57${=2$hFB#d*ca z&NpsU6~#3sP&yZhMBVYA57u#^9N|j zzV_zui;tO496BPsDcD)pQl7E}K?37Yd!C)0Xc=a-whYF6L~&;HIGrK;2wsl;XvxeX z5Zo3LUxp!)n}YLoEG^M82x90q4(=ZVzL7P!@-}ZP*HI-UA}A?%VGvM{(p=+N>B1An zqy&kiS)o9Mp%QOx__?^G15^^te0Nx!>zcE(b8=kVVuobE*8-R@alG-0VjtmA;qtc- zZN#;#cg=gY(f|1hv`RH;5oo!9geb65QQjac7^0ADx{dNHu>^sjykE-3ve^&!)jmJ-{}#zxBjTp#G!5Db?3Lcwtsa6yLlQEN9m6nD5TVfskT3;vppjt)rws|kFtwF$)@PN+m%pJ{4!E&{<}&nq1^ zR;&|o``!@XJ*|Y`xcS+OV}wPp27aXlLdNUK%FJA?)Tq3Q8wvc>us5RN@PnYBpe(rE z;*Z6SV%Y|;^Uj}BNE49I@z8hFPh@eqjEM0$zF~xn6SSkC9y||suYEuIBYhlv} z-ynMh1}0EDI*NQ31(GBo7nG#ews58md;QA@F(5#OcH0VOz3PqSt?`)vgJkYk9Y(^i zUO`OL6n$9@EgPk~wc;^MmfQ8Xm6cV{41}sY*Le0LHA0VT?IKp@_XQ3fUIM>;7`8t> zjW@-@IIHIXTg~KdiXMRixwcCGmp)sE*SG2t%w?lmJa?>u$FuZ|@9w?+j4lbX@;RBA znQrQ6hgbSwXm{E_efl(xw)}wtW%7j;dt^9-^n+I-16P9`h=cGWGUb}NHwj!@tM|Z^ zWO123Tyo}JFGsfcMwDVr>~x{ZAf{|X)^Te5$5!Gxf15k#%mz(cpO zGuC_Iw>N2Qfs;b;@8yRu1+8^PaQSc-TVCN52=06!1DEdwVfa81&S?Mtghg%EXw77_ zUr)r7Ek?)2I_~f~VV#^z6`z^x4u2{+$nXZxaNW`I?4CVoq9P-dH+O>~o9)H+K3P39 zDa_EWRIHk`qU;h>&oR;6YW=76ezUw!&rd&=wXCbDNtv7Idr?)TG3Z(*yEo)0M_$Ab zF*I#%ZXTss5J{wiN=MCxk@KmBpCRb4_2k zFATDjm5;ce zidS|fkBnT~Nq^zV8Sl^M$VJ;|Ijc-194V(OE2+I_RnZoEw@{VLBHDsNY~ zj&BQ|?_T@IsQtwr<8ir(^n}L`9;{9bSW&!M7$Lf2$2#BT*}#R0+NF&iLWoIvMTHf` z+O0T5p+!*y<33R~EECgs{o=(Miw#?2tF4+OjI_9F66CB(&f+K}m&GB^=NWpfQCXO=8%IV4^x>E^OzXxe zul9d)UIo%KIp6M3>*ERO=B2*ND-T6Wgd>Sr4)}|uDAVU@X(zYZxQEk4l-~Wm$&Y*- zU{)P0`>~0lV2`I|#C(|dqbTHtaTan~2#FjVczi3NcjAb7@Tn8(>Mv~Svr(Ka6EZ^N zUzo^dz;;m-K9c+Th$gaws56}$eLa7~9cW>5^UbuO@8UD^moM3ajjf&u&wYNc{4`fh zo>|_7e$?B9`w9%&y{Y1R|N1(7N1LlA#Fv`?i`irYc4vZ0^bo^*{r%h-xD|cCVgPlb zHu``{JN}+GX61%Lz^QG#$YGFm7fZeTcBQy+@&8 z50sflII4^URi=f9|8Ig9Q>Y-gJZj$GtP%R}=SRN1IZ4`MGWN6o9cfR~c82HUg*_i- zkXWOm@txpZbP+c)m74E(l#Z%<&GCchA+QF>>#1!Z(nC|M^` zzoE`_@!!8QH%XoR#}*E{s*HcF?7@e;9PcgDE3TKS;88w`cp;&jU;Ucl~yn(t{u2oU)eU1z-$T(iRrUQpmwhZ>#H9q&g_F*;V z;v%<=vpPo)_Y$Z4s{QrtcSTxSSrxl{UyXNnU-hyI7UtAlWP!n8Zs+BH{T+?KC31hN z8N4tfwhhMEWuDq?6oD7w=bfFK8|`vP{*E1v_#J8>YUNJ**{Q3mk4{V&!w{y?aFi*+ zQ4WrO`P_I?-~J8TlKD3CBiP+hy4M$`2Q3Q*xbYqyWr3QX}Oz)C~9lzfj z$Q>A`mla=hrD2nt?duBw!P$XaHVWmReI&VcKSU)6<8ag;ot&hko`f*2kJ1-k&)F}Z z(_F~zvG(MZj@SMyQpL(^T{P0MJ!uu+|7ABp@zATUzwh8BK>rM@QQpZQ0+^ z*_i>qEcjcl-FgsWOtd7}ikOP)sb^=+mY0{i3mu-!jdi89{lH zM=jsLb#cX)N@3>|k8w+)RjED%)YK3KhGk~48QAun634FR@H4hOv-zWap{ee=8YzB6 z@G^wxkNfjG-d#0Gelr4yYHQLmN zONwduUht$YOZDBFto(RIk@Ovr+02XZ1(#OMqWZjt#ZZ&48P?h=89Zw?j67le(xj*7VZw>d0Zjkm($b1B?)T!n$W56jUD^t>I%i_yAjOEg5NithBs#;dWguYrSM1TU z?sKV6rj0uXRM16kTa~-#SXx@nce<42U8f)~H0Xg+n6S1m!M#%7g?j6@ix8 z^at8lBbn0C5m{GPcSKP!_x5dnF*PrlX*<FDz$i@>y~9>ea=bleV+7v!^GHD-I`mCLMMDnqE?3MlnhN zgwgq6{q{YcMmu{n-$HoE#ooAKX3&bo$D{qy0jE#ggl{Wi*-)={- zqWUD#MezOM+Txu|DvJv+!kux6ODDLrOudq_OkbGMrW+5b`d=&ThCBl4V7Avy)#QLM z?q%4{-ka~%mrqtzdy1uzUQhZ}r`3xytLP$IFidR`$b8Nak-A^G+yz4r`LOulLD)6M zP_w9**!nAeoj;gW{!8D91+NC358C?Bq=*)U2P20oyH+-u_3{RKTYy`9oynvcy1BVs zq@T@dXYco!GiQ_(6*qnHdC`vnN@&unUe&rjTNU^o@Z+|_t!m#<9~1su-qUo9JMawfVbbUa`+;PF68Q+P(B-HnvG#9@{2o@$x-BvfTaG&r6jnv*y=aUEA=H zFX8zy_VezZp7b~zDO#br&jQPm_yi)t%AWSe(VR*y659_V{ABKdV6 zO}}^tJi*V845b$ro8rjU=gpnl>O}qx1zj!KP!mAx5WLE&%D0SEwTPuC=szBpFoGTG z0=QgExsli9q|XS~ku36G_Hh`-IJnV?rR8CQqNTml;*^d0!65vOeCOrtOx}huvn*YA zclVL!j}N+veOGFuxgSWLx6APaAJoliCt+T%?$LY>x%lO;s(ihJW$*DJ7B;Xw(|%NZ zhI_*K|0Sk&h&Nkq|>ybA?y0smNmhuHeMWN9qZ3{;v6 zd>0l7VH(B6O|$M57E(iua@LH2W`2yVPtM2Sp&`h4Y8^eA@|(i|99B)5FZm4}ggi^G zjrt)b)*o+Ed7Eynx1TR!nq}@f5;CGjq`~W18o6j+jf89T)Ozny%A{2#uG2Z z?ac*x3^V?LRXb14`Fsh!Z>ruz5-&;By|1Er=QzCU?UJ;yKo@=2$qPov(B7u5jqCn8 zGS-9uR-^N6c@Z==`*c?xoNukQ`DR(He+_#6e81o3oFL_zzeWxFYw~qmZ1lAH4z7z#?Z|D^ z`TfkdH{RFX-)fr;89|q#F!UoQD~a5aI8iO&2U)wjjKRANW3By1lyOe_#KcNRU-->yAW9<~t$$4PtvBL-$`!!}0%N%IqZ zAni2VV)Jp(75sJn{bqw#VS*@dVZ2!XF))Jv0*Sq%h*oq52Vvn^v$)@;9Vr#jaE&?V zU{fF4IXB)zpBTtG7k?Dz?%sqC`6MDH#zwZ~VN!XHt(QWPV7S`T%l1XMd! z!^0Xuz1>%^yazCLEad96AO|8bxk8&*-2L(@&PH?5y{9DWm14y@=G=HY;NU@2-F1-G z1?j>VJO47$*Yr}r(41U}X@UY5E$J5U|7ZFtm=m`~vBN&>8S{&;y7wO;_1u#pG6d8l zGRdYQAuoVroD=CkSLnb-q7g3X$B>%;|uq&aEi6DD7S>bRmj^5}^2@4Bj zANG%=vsLSOz_*mVP?f(ox%W9IzDAa}!J!AjJdP8~RUi*ta9M>gfN#uDzcmNvbXv&a zmh7&q)Dx*G!%4UFtx7ev#l3sK7kJ$63eWtnjHGzL5BrmUy&vx`JDd>olAVusKqiE@ zBqfRCJw5+Uqs6!wNTb9?=DYnufi zDJ?B6kD@=rG0u-Umy_=t@IzxN!L3EbXCS!Q^3RlJyigWoxuP3=tCy!^s@=sha>2M( z3$cMkU%HfD^tGvhS}YEC9@GIk2Sy-0EvVM5EP;(HP^eruC5WNib-EmXwXA8a#ZxLz z1EQp0n5J*YmK=2PPC#=ErF*VmIn5R*r=W>f-&knq5xor-+TqNvv%wJtFTQEx7zjQ zlvYAI78myr<9(*8uY8RNH#{f_h#ip6*Is1;u~&?(a;q~N9$@9Gaw~M+~)%z z)~q)hk(4m?=?`h$fA(7f;KY#9BFEYL9jAorTHwKl?LoA3tN~iP11`JP_3|nyEun2? znK}Ec)z|Ea<_Zsh+Q{47$UPl^iEA`}9rv4#Nd5$PaS#=jo;E*6b!&}rpR5eXZ$Ky6 z3EblaT+iMF>LPJ|6ySnQW6U8>g6zrGeF(DIpWO>^iCX1X4sJR_bna{2<9AySxWZkY z!1auht(-rzPb6>(%A=K^(XOBQf_VZ6capN%!10e+8-A@`(9OeZSZv$kEFFLYihbaD zVV)m7+&qb*LSX&+)fNumrw!B!PD3Odl-Cs82#1&FP&{yNkjm$+Bpe$t0u$LD1iSDfEd!c6GJgt+}z!uvta7=5MGo$J|h^KOTNJ=S~rZW5zwd#rEVl z$|My}NjBg2N1)F;xBod3^#ASP$suk({Hk)c3DPS1zo$}xGm!=_KKSZl8IWeFrymQO z7KPlteH<7abI4+ljZF1LoH*xo`4l827s_>=?S+;$Ha1vC$Gne;DS=)B#I)Z#$&6^3(RiqO>{n_tcE1q+==gZ2TkE)C!G*jA)Z0fK9^?#U zK%m*<*^w2FN2}TN#-8ri-i2qhwRtS;Dmf(FJ;;8HmyTVEY^t(ZV>Uq_jxvzTMuF?t z;9fcVn|85Z4<#Nq(#f3(W+dwz2*D=f*2C6EjvN_TV#Mr=Pwe>i?cz6j{u<6|{!Bws zcK7x5l@G9f#n_tn(a3^2e8;8!+0NfNLiRs$;8AB_#a3L zzx8Dbsvo_xHacjay|oBXn4Fz|)pw!Jzv;W;%eDNJjY9`Qo*C>7$(Q=~*G4tq_I@z^ zYpD(s#tsfyZL}h~2;*-Kl#_!u%9dPdc=k^c5o7@%TD|rvp!5BQywFhnW z-TxvGxb57z`}5}{`JDrQE0m28F_<)$+Xta7UgWd37ZLr zg2onpUSPt_fU65&`Tt`J{|kOJSn2G((x}(o*wQ4dxDW)#Q8sidrU7lgv>X%?QXK7~;jt zCctwx6&}~?!)(P}71we$4*-PWW?-oxX;L0-*_vHgSor+v%=|nKa^@(#r?s$o_#tP! z{}a6Gm^zsOL^VVuplX4(FopBV1~F8+qeq*|^fgZW>DbRXf4z@Dpn>9lrhNC`GuMX* z6L&Fiw^g|#=s5R8_Lb}~`s43XWz=^*$gER`2hktPIkn3uhU9wA)g4SogEwJA#Rhob z4u&X}337s(fX$#VS!by_f<}$}u8IzO-%V1m&!_K%+T<~EH&_0J0~4;$y@Ra6*2E_y z_@dR;3nGDEDA)4cQ5dqI-QzYqAPn#bIn|R2Ef{v_y+VQ32TQfv8ab6{ z%Ud3cny({PZnF!przlUdSl>-~Yb=?|oa*&X5sbbKyP-rp{Kyus-O#vzt93{DErnH^ zZwcI^9TuX~y~7I%$cDI{HLF+GWwLHUah)(@7nhwa*P+QeRO&!w5^J@{R#I;0Vr3h|3qMpQ1orvU3fgYP4g6r*c z(3jGn_g75*LdE&zZzCfkPt&z=NwABn`jwb7?TOq^p^2>99xBCLUf!2450pi)ri*xY zLQ9`{+aa9jAJ7#LSv<#;I51JwE{veP(RM@&_IljvV%O*|%pYDROfntxx)1F$x_cJp zRkEn~tckyQiZ2kpxzJRisVoSqlDnPhM+D~M3pi@mGc|zy#ll3_f__Qw7I_d=bR(U{XVztQ%jDnr^3&>9|9FAeHhfq8h=Xg~0 zyD~HS)+#N6wA6Cf61%xTlsVt!vC`Jd&z?IK-EHo|-l6m4ZkAfh~~V1*$>_9^;OfQc~&g_Bl&xf z`Y+Jm<6`;=dbP9$bu-!|vhO5TODdV{(JHvGU#<#xFB;1Ik3R zkW48*YQUK`dhRPQgP)cW8OGOVfBd*&m$5dxZeQT_^L4QTI&rxJiqKz(>r+XFeaSeF zv_m^x>J>$cr!$$@gIf_~$5o=A0`#ESZAoG#n{{&UL_?=feZv*kv~1M>vDlIbEmg5H-bwQl{r&xkot78n^tpD#G+gmF zXk+Dan%!=z*4Njwh;eK4hTIVSeb6%oRX?vYZFe}$b9{?zlRaZv)m)Vcuih8aI9@>Z zKSdzUmP7eBcItA$FRR=)hdruZ9cZFFt$skaeDyB^MRA%eVhvD9X$0jbYu%01<* z*xya@*=rqR14HQe+nE=l5TK%<;9}k!ZTEsV`AqlxvfMxm#2owS?`Ngmh}d6V%W-x* zHwlh8CO)O<@3``zS?)_~yL~|i8 zuBULwVw`VfeBHHg`0_G1M=E&7GN_TZ(@glw>A=0vML3FcbnL~2l%e0)JqDYwQK<8k zjcD*y%AQr(j}S~`gghi88qia-Z;(JQrm=LyLc~txB86mV*hRQ!-oZ?6pchiit+(=O5kO1T5VK1}FeDdtLn-12 zjatqzkuC+(<@-2q^^b+!(TWC>NyElYYw{H$p*x5Z<{SSJiQ%kX14q6PHI zo7l^;e+mtX0Ii@$`UCCW0AEcJZ28V8U8j!En_wr@Q5T7Q7g|*Va#{{?t3@?5G+0Ap ztQkZvU7xfQ9zxJ)b=^v;QUyF}1ogr3L?vU!kF^?Y7gO|!-6K8brvlq(ydjBh7i_o{ zLF;B1_W_z>NzyK}byjfX2C(;ocK1iVce9;oTOCRjdtYyQrcM>{p~)V4Ca<&YtzvZHuJKM?EwFgL6k!nzZ>r zD=NF$_*9!-hB1u?n)eM}ZBUd*)#SbT@MNJE7|VqpMCwJT^P0P9tf-!amI}LySL-HZN=8MzZw|iC|W4f)6oCcRP&7_-hoBCYcgZGY$llO%NB-ao=3h zPa~pXN#OL)&8&t2OC+h2aD zJ`-3x*VyIg5f2UhfO*98!G1#tHwwvB=Z)XucOo?*Ny>Fg0Lb4_L&L&0lx;ZwLg|#6R~%w9YR)y5s@Z9+J=96>uPYGvmI=~* d?`w-#64!_F=PcZI6CkD|=+pYANGC4d`Cqg?&W8X1 diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index 881fe9368246877117778aec7c42ae2438ba615d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 316 zcmZQzU<5(|0R|vYU{GUZU=RbcIs^RNdAX#xfJ|Ob50@Yy4N@V%!3HGdKmShzQg1z7 z978JR%$
xh9s+kVYSOP%K{hgS>a|6+QbSj%_+pnDO2Q~JT~cS_79)8UJ$u>CeJ0b|G-r)#ee>`5bDirnT-Mi^*rK1XIl|I+ zdgqDXrwk5wezMswHqrc=?eknQCyN<3bw8Z^5IVi%0OO5qxxYX0s+WeHI()}q0?^|O Mp00i_>zopr0KRL4Z2$lO diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 2f18ffca..3d6a65c0 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,16 +1,7 @@ - - - - - - - - - - - - - - - + + + + + + \ No newline at end of file From 8811640d792ef6fde9d70d0474e9313536c96057 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 15:52:11 -0700 Subject: [PATCH 063/100] fix: remove overlap validation from time block create/update - drop _check_overlap, _intervals_overlap, and _to_minutes helpers entirely - remove _check_overlap calls from POST and PATCH routes - overlapping blocks are now permitted per revised design requirements --- backend/app/api/routes/time_blocks.py | 70 +-------------------------- 1 file changed, 1 insertion(+), 69 deletions(-) diff --git a/backend/app/api/routes/time_blocks.py b/backend/app/api/routes/time_blocks.py index 6cedade8..e8d6ef3f 100644 --- a/backend/app/api/routes/time_blocks.py +++ b/backend/app/api/routes/time_blocks.py @@ -1,74 +1,13 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from app.db.session import get_db -from app.models.models import TimeBlock, Event, User +from app.models.models import TimeBlock, User from app.schemas.time_block import TimeBlockCreate, TimeBlockUpdate, TimeBlockRead from app.core.permissions import MANAGE_EVENTS, VIEW_EVENTS, require_permission router = APIRouter(prefix="/tournaments/{tournament_id}/blocks", tags=["time-blocks"]) -def _to_minutes(t: str) -> int: - """Convert 'HH:MM' to minutes since midnight.""" - h, m = t.split(":") - return int(h) * 60 + int(m) - - -def _intervals_overlap(s1: int, e1: int, s2: int, e2: int) -> bool: - """Return True if two time intervals overlap on a 24-hour clock. - - Midnight-spanning blocks have end <= start. Each is split into up to two - sub-ranges so wrap-around is handled correctly. Adjacent blocks - (end == other.start) do NOT overlap. - """ - def to_ranges(s: int, e: int) -> list[tuple[int, int]]: - return [(s, 1440), (0, e)] if e <= s else [(s, e)] - - for a, b in to_ranges(s1, e1): - for c, d in to_ranges(s2, e2): - if a < d and b > c: - return True - return False - - -def _check_overlap( - db: Session, - tournament_id: int, - date: str, - start: str, - end: str, - exclude_id: int | None = None, -) -> None: - """Raise 409 if the given block overlaps any existing block on the same date.""" - cand_start = _to_minutes(start) - cand_end = _to_minutes(end) - - query = db.query(TimeBlock).filter( - TimeBlock.tournament_id == tournament_id, - TimeBlock.date == date, - ) - if exclude_id is not None: - query = query.filter(TimeBlock.id != exclude_id) - - for block in query.all(): - b_start = _to_minutes(block.start) - b_end = _to_minutes(block.end) - - if _intervals_overlap(cand_start, cand_end, b_start, b_end): - raise HTTPException( - status_code=status.HTTP_409_CONFLICT, - detail={ - "message": "Time block overlaps with an existing block.", - "conflict": { - "id": block.id, - "label": block.label, - "date": block.date, - "start": block.start, - "end": block.end, - }, - }, - ) - @router.get("/", response_model=list[TimeBlockRead]) def list_time_blocks( @@ -93,7 +32,6 @@ def create_time_block( current_user: User = Depends(require_permission(MANAGE_EVENTS)), ): """Create a new time block.""" - _check_overlap(db, tournament_id, payload.date, payload.start, payload.end) db_block = TimeBlock(**payload.model_dump(), tournament_id=tournament_id) db.add(db_block) db.commit() @@ -120,12 +58,6 @@ def update_time_block( update_data = payload.model_dump(exclude_none=True) - # Determine the effective date/start/end after applying the patch - effective_date = update_data.get("date", db_block.date) - effective_start = update_data.get("start", db_block.start) - effective_end = update_data.get("end", db_block.end) - _check_overlap(db, tournament_id, effective_date, effective_start, effective_end, exclude_id=block_id) - for field, value in update_data.items(): setattr(db_block, field, value) From a4a09ee2563dc1c9e8b7bda3aab4db158b33cefe Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 15:54:36 -0700 Subject: [PATCH 064/100] feat: drop blocks column from events CSV import/export - remove blocks column parsing from parseEventsCSV; silently ignore if present - show preview notice when a blocks column is detected in the uploaded file - remove blocks column from export output and CSV template - remove blocks entry from import help guide - block assignments must now be done inside NEXUS --- frontend/components/events/CsvImportBar.tsx | 99 +++++++++------------ 1 file changed, 42 insertions(+), 57 deletions(-) diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx index aa4baf68..e7ef7c3a 100644 --- a/frontend/components/events/CsvImportBar.tsx +++ b/frontend/components/events/CsvImportBar.tsx @@ -52,45 +52,41 @@ interface ParsedEventRow { room: string; floor: string; volunteersNeeded: number; - blockLabels: string[]; } interface EventParseResult { - valid: ParsedEventRow[]; - errors: Array<{ rowNum: number; message: string }>; - newCategories: string[]; - unknownBlocks: string[]; + valid: ParsedEventRow[]; + errors: Array<{ rowNum: number; message: string }>; + newCategories: string[]; + hasBlocksColumn: boolean; } function parseEventsCSV( text: string, categories: TournamentCategory[], - timeBlocks: TimeBlock[], ): EventParseResult { const rows = parseCSVText(text); if (rows.length === 0) { - return { valid: [], errors: [], newCategories: [], unknownBlocks: [] }; + return { valid: [], errors: [], newCategories: [], hasBlocksColumn: false }; } - const headers = rows[0].map((h) => h.toLowerCase().trim()); - const col = (name: string) => headers.indexOf(name); - const nameIdx = col("name"); - const catIdx = col("category"); - const divIdx = col("division"); - const typeIdx = col("type"); - const bldIdx = col("building"); - const roomIdx = col("room"); - const floorIdx = col("floor"); - const volIdx = col("volunteers_needed"); - const blocksIdx = col("blocks"); - + const headers = rows[0].map((h) => h.toLowerCase().trim()); + const col = (name: string) => headers.indexOf(name); + const nameIdx = col("name"); + const catIdx = col("category"); + const divIdx = col("division"); + const typeIdx = col("type"); + const bldIdx = col("building"); + const roomIdx = col("room"); + const floorIdx = col("floor"); + const volIdx = col("volunteers_needed"); + + const hasBlocksColumn = headers.includes("blocks"); const knownCatNames = new Set(categories.map((c) => c.name.toLowerCase())); - const knownBlockLabels = new Set(timeBlocks.map((b) => b.label.toLowerCase())); - const valid: ParsedEventRow[] = []; - const errors: Array<{ rowNum: number; message: string }> = []; + const valid: ParsedEventRow[] = []; + const errors: Array<{ rowNum: number; message: string }> = []; const newCategoriesSet = new Set(); - const unknownBlocksSet = new Set(); for (let i = 1; i < rows.length; i++) { const row = rows[i]; @@ -134,14 +130,6 @@ function parseEventsCSV( newCategoriesSet.add(categoryName); } - const rawBlocks = get(blocksIdx).trim(); - const allLabels = rawBlocks - ? rawBlocks.split(";").map((l) => l.trim()).filter(Boolean) - : []; - for (const label of allLabels) { - if (!knownBlockLabels.has(label.toLowerCase())) unknownBlocksSet.add(label); - } - valid.push({ rowNum, name, @@ -152,7 +140,6 @@ function parseEventsCSV( room: get(roomIdx).trim(), floor: get(floorIdx).trim(), volunteersNeeded, - blockLabels: allLabels.filter((l) => knownBlockLabels.has(l.toLowerCase())), }); } @@ -160,7 +147,7 @@ function parseEventsCSV( valid, errors, newCategories: [...newCategoriesSet], - unknownBlocks: [...unknownBlocksSet], + hasBlocksColumn, }; } @@ -258,12 +245,10 @@ function todayStr(): string { function buildEventsCSV( events: Event[], categories: TournamentCategory[], - timeBlocks: TimeBlock[], ): string { - const catMap = new Map(categories.map((c) => [c.id, c.name])); - const blockMap = new Map(timeBlocks.map((b) => [b.id, b.label])); + const catMap = new Map(categories.map((c) => [c.id, c.name])); const rows = [ - ["name", "category", "division", "type", "building", "room", "floor", "volunteers_needed", "blocks"], + ["name", "category", "division", "type", "building", "room", "floor", "volunteers_needed"], ...events.map((e) => [ e.name, catMap.get(e.category_id ?? -1) ?? "", @@ -273,7 +258,6 @@ function buildEventsCSV( e.room ?? "", e.floor ?? "", String(e.volunteers_needed), - (e.time_block_ids ?? []).map((id) => blockMap.get(id) ?? "").filter(Boolean).join(";"), ]), ]; return rows.map((r) => r.map(escapeCSVField).join(",")).join("\r\n"); @@ -296,9 +280,9 @@ function buildTimeBlocksCSV(timeBlocks: TimeBlock[], events: Event[]): string { // ─── Templates ──────────────────────────────────────────────────────────────── const EVENTS_CSV_TEMPLATE = [ - "name,category,division,type,building,room,floor,volunteers_needed,blocks", - '"Sample Event A",Science,B,standard,Main Building,101,1,3,"Morning A;Afternoon B"', - '"Sample Event B",,C,trial,,,,2,', + "name,category,division,type,building,room,floor,volunteers_needed", + '"Sample Event A",Science,B,standard,Main Building,101,1,3', + '"Sample Event B",,C,trial,,,,2', ].join("\r\n"); const TIME_BLOCKS_CSV_TEMPLATE = [ @@ -318,7 +302,6 @@ const EVENTS_HELP_COLUMNS = [ { col: "room", req: false, desc: "Room identifier. Free text." }, { col: "floor", req: false, desc: "Floor identifier. Free text." }, { col: "volunteers_needed",req: false, desc: "Integer ≥ 1. Defaults to 2." }, - { col: "blocks", req: false, desc: 'Semicolon-separated block labels. Unrecognized labels are skipped.' }, ]; const TIME_BLOCKS_HELP_COLUMNS = [ @@ -487,7 +470,7 @@ export function CsvImportBar({ if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { - const result = parseEventsCSV(ev.target?.result as string, categories, timeBlocks); + const result = parseEventsCSV(ev.target?.result as string, categories); setPreview({ kind: "events", result }); setImportError(null); }; @@ -522,7 +505,6 @@ export function CsvImportBar({ const created = await categoriesApi.create(tournamentId, catName); catMap.set(catName.toLowerCase(), created.id); } - const blockMap = new Map(timeBlocks.map((b) => [b.label.toLowerCase(), b.id])); for (let i = 0; i < result.valid.length; i++) { const row = result.valid[i]; @@ -535,9 +517,7 @@ export function CsvImportBar({ room: row.room || null, floor: row.floor || null, volunteers_needed: row.volunteersNeeded, - time_block_ids: row.blockLabels - .map((l) => blockMap.get(l.toLowerCase())) - .filter((id): id is number => id !== undefined), + time_block_ids: [], }; await eventsApi.create(tournamentId, body); setImportProgress({ done: i + 1, total: result.valid.length }); @@ -586,7 +566,7 @@ export function CsvImportBar({ // ── Export ─────────────────────────────────────────────────────────────── const handleExportEvents = () => { - downloadCSV(buildEventsCSV(events, categories, timeBlocks), `${tournamentSlug}-events-${todayStr()}.csv`); + downloadCSV(buildEventsCSV(events, categories), `${tournamentSlug}-events-${todayStr()}.csv`); setOpenMenu(null); }; @@ -880,9 +860,9 @@ function ImportPreviewModal({ preview, importing, progress, error, onImport, onC const errors = preview.result.errors; const noun = isEvents ? "event" : "block"; - const newCategories = isEvents ? (preview.result as EventParseResult).newCategories : []; - const unknownBlocks = isEvents ? (preview.result as EventParseResult).unknownBlocks : []; - const hasWarnings = newCategories.length > 0 || unknownBlocks.length > 0; + const newCategories = isEvents ? (preview.result as EventParseResult).newCategories : []; + const hasBlocksCol = isEvents ? (preview.result as EventParseResult).hasBlocksColumn : false; + const hasWarnings = newCategories.length > 0; const title = isEvents ? "Import events — preview" : "Import time blocks — preview"; @@ -902,19 +882,24 @@ function ImportPreviewModal({ preview, importing, progress, error, onImport, onC )}
+ {/* blocks column notice (events only) */} + {hasBlocksCol && ( +
+ +

+ A “blocks” column was found and will be ignored. Block assignments must be done inside NEXUS after import. +

+
+ )} + {/* Warnings (events only) */} {hasWarnings && (
{newCategories.length > 0 && ( -

0 ? "6px" : 0 }}> - New categories will be created: {newCategories.join(", ")} -

- )} - {unknownBlocks.length > 0 && (

- Unknown block labels will be skipped: {unknownBlocks.join(", ")} + New categories will be created: {newCategories.join(", ")}

)}
From 631f10f9c67cf4f7e962356327a9fa78691a8ef8 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 16:04:39 -0700 Subject: [PATCH 065/100] feat: overlapping block timeline rendering and multi-select mode - add resolveColumnLayout utility with union-find overlap detection; export ColumnLayout type - render overlapping block header cells with amber-50 (#FAEEDA) tint and hover tooltip - add selectMode/selected/onToggleSelect props to EventCard with checkbox overlay and accent border - add Select button to EventCardGrid toolbar; route card clicks to onToggleSelect in select mode - add select props to EventTable; render checkbox column as leftmost col in select mode - add Select button to EventTable toolbar; highlight selected rows with accent background - manage selectMode and selectedIds in events page with shared floating toolbar - floating toolbar shows selection count, Edit button (disabled at 0), and exit --- .../dashboard/[tournamentId]/events/page.tsx | 83 +++++++++++- frontend/components/events/EventCard.tsx | 67 ++++++--- frontend/components/events/EventCardGrid.tsx | 26 +++- frontend/components/events/EventTable.tsx | 70 ++++++++-- frontend/components/events/EventTimeline.tsx | 127 ++++++++++++++++-- 5 files changed, 326 insertions(+), 47 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 92baee7c..fb0029b0 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -150,7 +150,7 @@ export default function EventsPage() { // ── Event side panel ───────────────────────────────────────────────────── - type PanelMode = { type: "add" } | { type: "edit"; event: Event } | null; + type PanelMode = { type: "add" } | { type: "edit"; event: Event } | { type: "multi-edit"; ids: number[] } | null; const [panel, setPanel] = useState(null); // Normalize a single freshly-fetched/updated event the same way loadAll does @@ -198,6 +198,20 @@ export default function EventsPage() { return cat; }; + // ── Select mode ─────────────────────────────────────────────────────────── + + const [selectMode, setSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const handleEnterSelectMode = () => setSelectMode(true); + const handleExitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()); }; + const handleToggleSelect = (id: number) => + setSelectedIds((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + // ── Delete block ────────────────────────────────────────────────────────── const [deleteTarget, setDeleteTarget] = useState(null); @@ -294,6 +308,10 @@ export default function EventsPage() { categories={categories} onCardClick={openEditPanel} onAddClick={() => setPanel({ type: "add" })} + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} /> )} @@ -305,6 +323,10 @@ export default function EventsPage() { onUpdate={handleUpdateEvent} onCreateCategory={handleCreateCategory} onAddClick={() => setPanel({ type: "add" })} + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} /> )} @@ -320,6 +342,65 @@ export default function EventsPage() { )} + {/* ── Select mode floating toolbar ── */} + {selectMode && ( +
+ + {selectedIds.size} selected + +
+ +
+ +
+ )} + {/* ── Event side panel ── */} {panel && ( void; + event: Event; + categories: TournamentCategory[]; + onClick?: () => void; + selectMode?: boolean; + selected?: boolean; + onToggleSelect?: () => void; } // ─── Small badge ───────────────────────────────────────────────────────────── @@ -48,7 +51,7 @@ function Chip({ // ─── Component ──────────────────────────────────────────────────────────────── -export function EventCard({ event, categories, onClick }: Props) { +export function EventCard({ event, categories, onClick, selectMode, selected, onToggleSelect }: Props) { // Category color const catIdx = categories.findIndex((c) => c.id === event.category_id); const catName = catIdx >= 0 ? categories[catIdx].name : null; @@ -58,32 +61,62 @@ export function EventCard({ event, categories, onClick }: Props) { const locationParts = [event.building, event.room, event.floor].filter(Boolean); const location = locationParts.join(" · "); + const handleClick = selectMode ? onToggleSelect : onClick; + const isInteractive = !!handleClick; + return (
{ - if (!onClick) return; - (e.currentTarget as HTMLDivElement).style.boxShadow = "var(--shadow-md)"; - (e.currentTarget as HTMLDivElement).style.borderColor = "var(--color-accent)"; + if (!isInteractive || selected) return; + (e.currentTarget as HTMLDivElement).style.boxShadow = "var(--shadow-md)"; + (e.currentTarget as HTMLDivElement).style.borderColor = "var(--color-accent)"; }} onMouseLeave={(e) => { + if (selected) return; (e.currentTarget as HTMLDivElement).style.boxShadow = ""; (e.currentTarget as HTMLDivElement).style.borderColor = "var(--color-border)"; }} > + {/* Checkbox overlay in select mode */} + {selectMode && ( +
+ {selected && ( + + + + )} +
+ )} + {/* ── Row 1: name + division ── */} -
+
void; - onAddClick: () => void; + events: Event[]; + categories: TournamentCategory[]; + onCardClick: (event: Event) => void; + onAddClick: () => void; + selectMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (id: number) => void; + onEnterSelectMode?: () => void; } // ─── Filter types ───────────────────────────────────────────────────────────── @@ -23,7 +27,7 @@ type TypeFilter = "standard" | "trial" | null; // ─── Component ──────────────────────────────────────────────────────────────── -export function EventCardGrid({ events, categories, onCardClick, onAddClick }: Props) { +export function EventCardGrid({ events, categories, onCardClick, onAddClick, selectMode, selectedIds, onToggleSelect, onEnterSelectMode }: Props) { const [search, setSearch] = useState(""); const [division, setDivision] = useState(null); const [eventType, setEventType] = useState(null); @@ -133,6 +137,13 @@ export function EventCardGrid({ events, categories, onCardClick, onAddClick }: P {filtered.length} event{filtered.length !== 1 ? "s" : ""} + {/* Select button */} + {!selectMode && ( + + )} + {/* Add event */}
diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 07159596..a1bec994 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -14,13 +14,17 @@ type DivFilter = "B" | "C" | null; type TypeFilter = "standard" | "trial" | null; interface Props { - events: Event[]; - categories: TournamentCategory[]; - timeBlocks: TimeBlock[]; - onUpdate: (id: number, delta: Partial) => Promise; - onCreateCategory: (name: string) => Promise; - onAddClick: () => void; - isReadOnly?: boolean; + events: Event[]; + categories: TournamentCategory[]; + timeBlocks: TimeBlock[]; + onUpdate: (id: number, delta: Partial) => Promise; + onCreateCategory: (name: string) => Promise; + onAddClick: () => void; + isReadOnly?: boolean; + selectMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (id: number) => void; + onEnterSelectMode?: () => void; } // ─── Shared styles ──────────────────────────────────────────────────────────── @@ -553,6 +557,9 @@ function EventTableRow({ onUpdate, onCreateCategory, isReadOnly, + selectMode, + selected, + onToggleSelect, }: { event: Event; categories: TournamentCategory[]; @@ -560,6 +567,9 @@ function EventTableRow({ onUpdate: (id: number, delta: Partial) => Promise; onCreateCategory: (name: string) => Promise; isReadOnly?: boolean; + selectMode?: boolean; + selected?: boolean; + onToggleSelect?: () => void; }) { const [editing, setEditing] = useState<{ col: TextCol | NumCol; draft: string } | null>(null); const inputRef = useRef(null); @@ -639,13 +649,38 @@ function EventTableRow({ return ( { (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-accent-subtle)"; }} - onMouseLeave={(e) => { (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-surface)"; }} + onMouseEnter={(e) => { if (!selected) (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-accent-subtle)"; }} + onMouseLeave={(e) => { if (!selected) (e.currentTarget as HTMLTableRowElement).style.background = "var(--color-surface)"; }} > + {/* Checkbox cell in select mode */} + {selectMode && ( + +
+ {selected && ( + + + + )} +
+ + )} + {renderTextCell("name", 200)} @@ -710,6 +745,10 @@ export function EventTable({ onCreateCategory, onAddClick, isReadOnly, + selectMode, + selectedIds, + onToggleSelect, + onEnterSelectMode, }: Props) { const [search, setSearch] = useState(""); const [division, setDivision] = useState(null); @@ -744,6 +783,7 @@ export function EventTable({ }); const cols: { label: string; width: number | "auto" }[] = [ + ...(selectMode ? [{ label: "", width: 40 as number | "auto" }] : []), { label: "Name", width: 200 }, { label: "Category", width: 200 }, { label: "Division", width: 90 }, @@ -849,6 +889,13 @@ export function EventTable({ {filtered.length} event{filtered.length !== 1 ? "s" : ""} + {/* Select button */} + {!isReadOnly && !selectMode && ( + + )} + {/* Add event */} {!isReadOnly && ( + ) : isMultiEdit ? ( + ) : (
+ )} +
+ {isMultiEdit && !meForm.timeBlocksDirty && ( +

+ No change — click a block or “Clear all” to set blocks for all selected events. +

+ )} {timeBlocks.length === 0 ? ( -

+

No time blocks have been created yet.

) : (
- {timeBlocks.map((block) => ( - toggleBlock(block.id)} - disabled={isReadOnly} - /> - ))} + {timeBlocks.map((block) => { + const selected = isMultiEdit + ? meForm.time_block_ids.includes(block.id) + : form.time_block_ids.includes(block.id); + return ( + { + if (isMultiEdit) { + setMe("timeBlocksDirty", true); + setMe("time_block_ids", + meForm.time_block_ids.includes(block.id) + ? meForm.time_block_ids.filter((x) => x !== block.id) + : [...meForm.time_block_ids, block.id] + ); + } else { + toggleBlock(block.id); + } + }} + disabled={isReadOnly} + /> + ); + })}
)}
@@ -779,6 +920,18 @@ export function EventSidePanel({

)} + {/* No-op notice (multi-edit: no fields dirty) */} + {noOpNotice && !error && ( +

+ No changes to apply — edit at least one field first. +

+ )} + {isReadOnly ? ( +
- ) : ( + ) : mode === "add" ? (
+ ) : ( + )}
From bbb7d512bdba41ddab695834b365d02aa0083820 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 19:43:09 -0700 Subject: [PATCH 079/100] fix: align event table header and improve horizontal layout - Fixed header/body misalignment by preserving standard header padding for non-checkbox columns - Increased Building column width and switched table to explicit min width so horizontal scrolling is available - Added sticky first-column behavior (and sticky checkbox + name in select mode) to keep key identifiers visible while scrolling --- frontend/components/events/EventTable.tsx | 115 +++++++++++++++++----- 1 file changed, 91 insertions(+), 24 deletions(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 8d1e5a8c..93b4d13f 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -13,6 +13,19 @@ type NumCol = "volunteers_needed"; type DivFilter = "B" | "C" | null; type TypeFilter = "standard" | "trial" | null; +const COL_W = { + select: 40, + name: 220, + category: 200, + division: 90, + type: 90, + building: 180, + room: 90, + floor: 70, + volunteers: 90, + timeBlocks: 520, +} as const; + interface Props { events: Event[]; categories: TournamentCategory[]; @@ -574,6 +587,7 @@ function EventTableRow({ }) { const [editing, setEditing] = useState<{ col: TextCol | NumCol; draft: string } | null>(null); const inputRef = useRef(null); + const stickyNameLeft = selectMode ? COL_W.select : 0; useEffect(() => { if (editing) setTimeout(() => inputRef.current?.select(), 10); @@ -602,7 +616,11 @@ function EventTableRow({ const revert = () => setEditing(null); - const renderTextCell = (col: TextCol | NumCol, width: number | "auto") => { + const renderTextCell = ( + col: TextCol | NumCol, + width: number | "auto", + stickyLeft?: number, + ) => { const isActive = editing?.col === col; const rawVal = event[col]; const displayVal = rawVal != null ? String(rawVal) : ""; @@ -618,6 +636,15 @@ function EventTableRow({ paddingLeft: 10, paddingRight: 10, overflow: isActive ? "visible" : "hidden", + ...(stickyLeft !== undefined + ? { + position: "sticky", + left: stickyLeft, + zIndex: 5, + background: "inherit", + borderRight: "1px solid var(--color-border)", + } + : {}), }} > {isActive ? ( @@ -661,7 +688,19 @@ function EventTableRow({ > {/* Checkbox cell in select mode */} {selectMode && ( - +
)} - {renderTextCell("name", 200)} + {renderTextCell("name", COL_W.name, stickyNameLeft)} - + - + onUpdate(event.id, { division: div })} @@ -702,7 +741,7 @@ function EventTableRow({ /> - + onUpdate(event.id, { event_type: event.event_type === "standard" ? "trial" : "standard" })} @@ -710,12 +749,12 @@ function EventTableRow({ /> - {renderTextCell("building", 110)} - {renderTextCell("room", 90)} - {renderTextCell("floor", 70)} - {renderTextCell("volunteers_needed", 80)} + {renderTextCell("building", COL_W.building)} + {renderTextCell("room", COL_W.room)} + {renderTextCell("floor", COL_W.floor)} + {renderTextCell("volunteers_needed", COL_W.volunteers)} - + {cols.map((col, i) => { const isCheckboxCol = selectMode && i === 0; + const isNameCol = selectMode ? i === 1 : i === 0; + const stickyLeft = isCheckboxCol ? 0 : isNameCol ? (selectMode ? COL_W.select : 0) : undefined; const allSelected = filtered.length > 0 && filtered.every((e) => selectedIds?.has(e.id)); const someSelected = !allSelected && filtered.some((e) => selectedIds?.has(e.id)); return ( -
+ {isCheckboxCol ? (
{ From 335b98ea6283caeaec385081fae235d54b1d2caf Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 19:46:43 -0700 Subject: [PATCH 080/100] feat: add smooth clamped horizontal scroll animation to event table headers - Added requestAnimationFrame-based header label transforms tied to table horizontal scroll - Shifted non-sticky header labels right as they enter the frozen first-column zone for better readability - Clamped each label by its own header cell width so labels stop at the right border without overshooting --- frontend/components/events/EventTable.tsx | 78 +++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 93b4d13f..af9346ae 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useRef, useEffect, useMemo, CSSProperties } from "react"; +import { useState, useRef, useEffect, useMemo, useCallback, CSSProperties } from "react"; import { Event, EventCreate, TimeBlock, TournamentCategory } from "@/lib/api"; import { catColorVars, fmtTime, fmtDateShort } from "@/lib/formatters"; import { Button } from "@/components/ui/Button"; @@ -23,7 +23,7 @@ const COL_W = { room: 90, floor: 70, volunteers: 90, - timeBlocks: 520, + timeBlocks: 1000, } as const; interface Props { @@ -795,6 +795,10 @@ export function EventTable({ const [division, setDivision] = useState(null); const [eventType, setEventType] = useState(null); const [categoryId, setCategoryId] = useState(null); + const tableScrollRef = useRef(null); + const headerLabelRefs = useRef>(new Map()); + const latestTableScrollRef = useRef(0); + const headerRafRef = useRef(null); const filtered = useMemo(() => { const q = search.toLowerCase().trim(); @@ -839,6 +843,19 @@ export function EventTable({ { label: "Volunteers", width: COL_W.volunteers }, { label: "Time Blocks", width: COL_W.timeBlocks }, ]; + const stickyPrefixWidth = selectMode ? COL_W.select + COL_W.name : COL_W.name; + const headerScrollMeta = useMemo(() => { + let start = 0; + return cols + .map((col, i) => { + const width = Number(col.width); + const sticky = selectMode ? i <= 1 : i === 0; + const meta = { index: i, start, width, sticky }; + start += width; + return meta; + }) + .filter((m) => !m.sticky); + }, [cols, selectMode]); const tableMinWidth = (selectMode ? COL_W.select : 0) + COL_W.name + @@ -851,6 +868,42 @@ export function EventTable({ COL_W.volunteers + COL_W.timeBlocks; + const applyHeaderTransforms = useCallback(() => { + for (const meta of headerScrollMeta) { + const label = headerLabelRefs.current.get(meta.index); + if (!label) continue; + const labelWidth = label.offsetWidth; + const maxShift = Math.max(0, meta.width - 20 - labelWidth); + const desired = latestTableScrollRef.current + stickyPrefixWidth - meta.start; + const shift = Math.min(Math.max(0, desired), maxShift); + label.style.transform = `translateX(${shift}px)`; + } + }, [headerScrollMeta, stickyPrefixWidth]); + + const onTableScroll = () => { + const scrollLeft = tableScrollRef.current?.scrollLeft ?? 0; + latestTableScrollRef.current = scrollLeft; + if (headerRafRef.current === null) { + headerRafRef.current = window.requestAnimationFrame(() => { + headerRafRef.current = null; + applyHeaderTransforms(); + }); + } + }; + + useEffect(() => { + latestTableScrollRef.current = tableScrollRef.current?.scrollLeft ?? 0; + applyHeaderTransforms(); + }, [applyHeaderTransforms]); + + useEffect(() => { + return () => { + if (headerRafRef.current !== null) { + window.cancelAnimationFrame(headerRafRef.current); + } + }; + }, []); + const thStyle: CSSProperties = { padding: "0 10px", height: "36px", @@ -1031,7 +1084,10 @@ export function EventTable({ overflowX: "auto", border: "1px solid var(--color-border)", borderRadius: "var(--radius-md)", - }}> + }} + ref={tableScrollRef} + onScroll={onTableScroll} + > )} - ) : col.label} + ) : ( + { + if (el) headerLabelRefs.current.set(i, el); + else headerLabelRefs.current.delete(i); + }} + style={{ + display: "inline-block", + transform: "translateX(0px)", + willChange: "transform", + }} + > + {col.label} + + )} ); })} From 59631a88e05bfd57378be826744f0b9d8a5a8472 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 19:52:25 -0700 Subject: [PATCH 081/100] refactor: use transform-based animation for event table header labels - Switched animated table header label movement back from left-position updates to translateX transforms - Kept pixel snapping with Math.round on shift values to reduce blur while preserving smoother motion - Restored will-change transform hints for more efficient scroll-time rendering --- frontend/components/events/EventTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index af9346ae..f0ce6774 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -876,7 +876,7 @@ export function EventTable({ const maxShift = Math.max(0, meta.width - 20 - labelWidth); const desired = latestTableScrollRef.current + stickyPrefixWidth - meta.start; const shift = Math.min(Math.max(0, desired), maxShift); - label.style.transform = `translateX(${shift}px)`; + label.style.transform = `translateX(${Math.round(shift)}px)`; } }, [headerScrollMeta, stickyPrefixWidth]); From d1d516ea8c0c3462f43037d8fc7607150ebee24f Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 19:54:56 -0700 Subject: [PATCH 082/100] fix: render frozen-column divider as inset line in event table - Replaced outer sticky-cell divider shadow with inset right-edge shadow to avoid clipping - Applied inset divider style consistently to sticky header and body first-column cells - Restored visible first-column boundary during horizontal scrolling --- frontend/components/events/EventTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index f0ce6774..3bc15594 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -642,7 +642,7 @@ function EventTableRow({ left: stickyLeft, zIndex: 5, background: "inherit", - borderRight: "1px solid var(--color-border)", + boxShadow: "inset -1px 0 0 var(--color-border)", } : {}), }} @@ -698,7 +698,7 @@ function EventTableRow({ left: 0, zIndex: 6, background: "inherit", - borderRight: "1px solid var(--color-border)", + boxShadow: "inset -1px 0 0 var(--color-border)", }} >
Date: Sat, 18 Apr 2026 19:57:01 -0700 Subject: [PATCH 083/100] fix: updated column widths for events table view --- frontend/components/events/EventTable.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx index 3bc15594..5e95300a 100644 --- a/frontend/components/events/EventTable.tsx +++ b/frontend/components/events/EventTable.tsx @@ -19,11 +19,11 @@ const COL_W = { category: 200, division: 90, type: 90, - building: 180, - room: 90, + building: 150, + room: 200, floor: 70, volunteers: 90, - timeBlocks: 1000, + timeBlocks: 800, } as const; interface Props { From e30d1096fbb5de7c43e9d099ba69d757c0597b60 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 20:07:49 -0700 Subject: [PATCH 084/100] feat: add shared cross-view event filters with modal and faster tab switching - Moved event search/filter state to the events page so filters persist across timeline, cards, and table views - Added filter modal with multi-select category (including no category), division (including no division), building, and time block filters - Applied shared filtered dataset to all event views and added a unified search/filter/add toolbar - Improved tab switching responsiveness with React transitions and by keeping visited tabs mounted - Updated EventCardGrid and EventTable with hideFilters mode for compatibility with page-level filtering --- .../dashboard/[tournamentId]/events/page.tsx | 345 ++++++++++++++++-- frontend/components/events/EventCardGrid.tsx | 26 +- frontend/components/events/EventTable.tsx | 41 ++- 3 files changed, 348 insertions(+), 64 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index dc4266b9..0e47f338 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useEffect, useState, useCallback, useMemo, useTransition } from "react"; import { useParams } from "next/navigation"; import { eventsApi, @@ -25,6 +25,24 @@ import { EventTimeline } from "@/components/events/EventTimeline"; // ─── Tab type ───────────────────────────────────────────────────────────────── type Tab = "timeline" | "cards" | "table" | "blocks"; +type NullToken = "__none__"; +const NONE: NullToken = "__none__"; + +type EventFilters = { + search: string; + categoryIds: Array; + divisions: Array<"B" | "C" | NullToken>; + buildings: string[]; + timeBlockIds: number[]; +}; + +const EMPTY_FILTERS: EventFilters = { + search: "", + categoryIds: [], + divisions: [], + buildings: [], + timeBlockIds: [], +}; // ─── Tab bar ────────────────────────────────────────────────────────────────── @@ -83,6 +101,103 @@ function TabBar({ ); } +function toggleInSet(arr: T[], value: T): T[] { + return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; +} + +function checkboxRow(label: string, checked: boolean, onChange: () => void) { + return ( + + ); +} + +function FiltersModal({ + open, + filters, + categories, + buildings, + timeBlocks, + onChange, + onClear, + onClose, +}: { + open: boolean; + filters: EventFilters; + categories: TournamentCategory[]; + buildings: string[]; + timeBlocks: TimeBlock[]; + onChange: (next: EventFilters) => void; + onClear: () => void; + onClose: () => void; +}) { + if (!open) return null; + + return ( + <> +
+
+
+

Filters

+ +
+ +
+
+

Category

+
+ {checkboxRow("No category", filters.categoryIds.includes(NONE), () => onChange({ ...filters, categoryIds: toggleInSet(filters.categoryIds, NONE) }))} + {categories.map((c) => ( +
+ {checkboxRow(c.name, filters.categoryIds.includes(c.id), () => onChange({ ...filters, categoryIds: toggleInSet(filters.categoryIds, c.id) }))} +
+ ))} +
+
+ +
+

Division

+
+ {checkboxRow("Division B", filters.divisions.includes("B"), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, "B") }))} + {checkboxRow("Division C", filters.divisions.includes("C"), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, "C") }))} + {checkboxRow("No division", filters.divisions.includes(NONE), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, NONE) }))} +
+
+ +
+

Building

+
+ {buildings.length === 0 ? No building values yet. : buildings.map((b) => ( +
+ {checkboxRow(b, filters.buildings.includes(b), () => onChange({ ...filters, buildings: toggleInSet(filters.buildings, b) }))} +
+ ))} +
+
+ +
+

Time Blocks

+
+ {timeBlocks.length === 0 ? No time blocks yet. : timeBlocks.map((b) => ( +
+ {checkboxRow(`${b.label}`, filters.timeBlockIds.includes(b.id), () => onChange({ ...filters, timeBlockIds: toggleInSet(filters.timeBlockIds, b.id) }))} +
+ ))} +
+
+
+ +
+ + +
+
+ + ); +} + // ─── Page ───────────────────────────────────────────────────────────────────── export default function EventsPage() { @@ -90,6 +205,10 @@ export default function EventsPage() { const tournamentId = Number(params.tournamentId); const [activeTab, setActiveTab] = useState("timeline"); + const [isPendingTab, startTabTransition] = useTransition(); + const [visitedTabs, setVisitedTabs] = useState>(new Set(["timeline"])); + const [filtersOpen, setFiltersOpen] = useState(false); + const [filters, setFilters] = useState(EMPTY_FILTERS); // Data const [events, setEvents] = useState([]); @@ -98,6 +217,14 @@ export default function EventsPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const activeFilterCount = useMemo(() => { + return Number(filters.search.trim().length > 0) + + Number(filters.categoryIds.length > 0) + + Number(filters.divisions.length > 0) + + Number(filters.buildings.length > 0) + + Number(filters.timeBlockIds.length > 0); + }, [filters]); + const loadAll = useCallback(async (silent = false) => { if (!silent) setLoading(true); setError(null); @@ -124,6 +251,53 @@ export default function EventsPage() { useEffect(() => { loadAll(); }, [loadAll]); + const buildingOptions = useMemo(() => { + const set = new Set(); + for (const e of events) { + const b = e.building?.trim(); + if (b) set.add(b); + } + return [...set].sort((a, b) => a.localeCompare(b)); + }, [events]); + + const filteredEvents = useMemo(() => { + const q = filters.search.trim().toLowerCase(); + const hasCategory = filters.categoryIds.length > 0; + const hasDivision = filters.divisions.length > 0; + const hasBuilding = filters.buildings.length > 0; + const hasTimeBlocks = filters.timeBlockIds.length > 0; + const categorySet = new Set(filters.categoryIds); + const divisionSet = new Set(filters.divisions); + const buildingSet = new Set(filters.buildings); + const blockSet = new Set(filters.timeBlockIds); + + return events.filter((e) => { + if (q && !e.name.toLowerCase().includes(q)) return false; + + if (hasCategory) { + const catVal: number | NullToken = e.category_id ?? NONE; + if (!categorySet.has(catVal)) return false; + } + + if (hasDivision) { + const divVal: "B" | "C" | NullToken = (e.division ?? NONE) as "B" | "C" | NullToken; + if (!divisionSet.has(divVal)) return false; + } + + if (hasBuilding) { + const b = e.building?.trim() ?? ""; + if (!buildingSet.has(b)) return false; + } + + if (hasTimeBlocks) { + const ids = e.time_block_ids ?? []; + if (!ids.some((id) => blockSet.has(id))) return false; + } + + return true; + }); + }, [events, filters]); + // Keep panel.event in sync with fresh data after any silent refresh useEffect(() => { setPanel((prev) => { @@ -131,7 +305,6 @@ export default function EventsPage() { const updated = events.find((e) => e.id === prev.event.id); return updated ? { type: "edit", event: updated } : prev; }); - // eslint-disable-next-line react-hooks/exhaustive-deps }, [events]); // ── Time block handlers ─────────────────────────────────────────────────── @@ -210,13 +383,25 @@ export default function EventsPage() { const [selectMode, setSelectMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [filteredIds, setFilteredIds] = useState([]); + useEffect(() => { + setFilteredIds(filteredEvents.map((e) => e.id)); + }, [filteredEvents]); const handleEnterSelectMode = () => setSelectMode(true); const handleExitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()); }; + const handleTabChange = (next: Tab) => { + startTabTransition(() => setActiveTab(next)); + setVisitedTabs((prev) => { + const copy = new Set(prev); + copy.add(next); + return copy; + }); + }; const handleToggleSelect = (id: number) => setSelectedIds((prev) => { const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); + if (next.has(id)) next.delete(id); + else next.add(id); return next; }); const handleSelectAll = () => setSelectedIds(new Set(filteredIds)); @@ -266,7 +451,74 @@ export default function EventsPage() { onImportComplete={() => loadAll(true)} /> - + + + {activeTab !== "blocks" && ( +
+ setFilters((f) => ({ ...f, search: e.target.value }))} + placeholder="Search events..." + style={{ + width: "min(320px, 100%)", + height: "32px", + padding: "0 10px", + fontFamily: "var(--font-mono)", + fontSize: "12px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + outline: "none", + boxSizing: "border-box", + }} + /> + + {activeFilterCount > 0 && ( + + )} +
+ + {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} + {isPendingTab ? " · switching..." : ""} + + {!selectMode && activeTab !== "timeline" && ( + + )} + +
+ )} {error && (
) : ( <> - {activeTab === "timeline" && ( - setPanel({ type: "add" })} - /> + {visitedTabs.has("timeline") && ( +
+ setPanel({ type: "add" })} + /> +
)} - {activeTab === "cards" && ( - setPanel({ type: "add" })} - selectMode={selectMode} - selectedIds={selectedIds} - onToggleSelect={handleToggleSelect} - onEnterSelectMode={handleEnterSelectMode} - onFilteredIdsChange={setFilteredIds} - /> + {visitedTabs.has("cards") && ( +
+ setPanel({ type: "add" })} + hideFilters + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} + /> +
)} - {activeTab === "table" && ( - setPanel({ type: "add" })} - selectMode={selectMode} - selectedIds={selectedIds} - onToggleSelect={handleToggleSelect} - onEnterSelectMode={handleEnterSelectMode} - onFilteredIdsChange={setFilteredIds} - /> + {visitedTabs.has("table") && ( +
+ setPanel({ type: "add" })} + hideFilters + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} + /> +
)} {activeTab === "blocks" && ( @@ -353,6 +611,17 @@ export default function EventsPage() { )} + setFilters(EMPTY_FILTERS)} + onClose={() => setFiltersOpen(false)} + /> + {/* ── Select mode floating toolbar ── */} {selectMode && (
void; onAddClick: () => void; + hideFilters?: boolean; selectMode?: boolean; selectedIds?: Set; onToggleSelect?: (id: number) => void; @@ -28,13 +29,14 @@ type TypeFilter = "standard" | "trial" | null; // ─── Component ──────────────────────────────────────────────────────────────── -export function EventCardGrid({ events, categories, onCardClick, onAddClick, selectMode, selectedIds, onToggleSelect, onEnterSelectMode, onFilteredIdsChange }: Props) { +export function EventCardGrid({ events, categories, onCardClick, onAddClick, hideFilters = false, selectMode, selectedIds, onToggleSelect, onEnterSelectMode, onFilteredIdsChange }: Props) { const [search, setSearch] = useState(""); const [division, setDivision] = useState(null); const [eventType, setEventType] = useState(null); const [categoryId, setCategoryId] = useState(null); const filtered = useMemo(() => { + if (hideFilters) return events; const q = search.toLowerCase().trim(); return events.filter((e) => { if (q && !e.name.toLowerCase().includes(q)) return false; @@ -43,7 +45,7 @@ export function EventCardGrid({ events, categories, onCardClick, onAddClick, sel if (categoryId !== null && e.category_id !== categoryId) return false; return true; }); - }, [events, search, division, eventType, categoryId]); + }, [events, search, division, eventType, categoryId, hideFilters]); useEffect(() => { onFilteredIdsChange?.(filtered.map((e) => e.id)); @@ -70,13 +72,14 @@ export function EventCardGrid({ events, categories, onCardClick, onAddClick, sel return (
{/* ── Toolbar ── */} -
+ {!hideFilters && ( +
{/* Search */}
Add event -
+
+ )} {/* ── Category chips ── */} - {categories.length > 0 && ( + {!hideFilters && categories.length > 0 && (
) : (
- {filtered.map((ev) => ( - 0 && ( + + + )} + {visibleRows.map((ev) => ( + onToggleSelect?.(ev.id)} /> ))} + {bottomPad > 0 && ( + + + )}
+
+
From 685ec02d19257e2835f1755b5c51726864ce3375 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 20:28:21 -0700 Subject: [PATCH 086/100] refactor: extract event filters panel and modernize shared events toolbar UX - Removed event count and add button from the top timeline controls header to reduce duplicated actions - Reworked shared search/filter/add bar to use predefined Input and Button UI components - Extracted inline filters modal into EventFiltersPanel with draft state and apply-on-Done behavior - Added improved filter UX with colored category/division tags and tokenized building/time block autocomplete inputs - Updated page-level filtering logic to support explicit and no-value category/division matching with persisted cross-view filters --- .../dashboard/[tournamentId]/events/page.tsx | 206 +++----------- .../components/events/EventFiltersPanel.tsx | 252 ++++++++++++++++++ frontend/components/events/EventTimeline.tsx | 31 +-- 3 files changed, 303 insertions(+), 186 deletions(-) create mode 100644 frontend/components/events/EventFiltersPanel.tsx diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 0e47f338..ad78fd4f 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -21,25 +21,20 @@ import { EventSidePanel } from "@/components/events/EventSidePanel"; import { EventCardGrid } from "@/components/events/EventCardGrid"; import { EventTable } from "@/components/events/EventTable"; import { EventTimeline } from "@/components/events/EventTimeline"; +import { EventFiltersPanel, EventFilters } from "@/components/events/EventFiltersPanel"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; // ─── Tab type ───────────────────────────────────────────────────────────────── type Tab = "timeline" | "cards" | "table" | "blocks"; -type NullToken = "__none__"; -const NONE: NullToken = "__none__"; - -type EventFilters = { - search: string; - categoryIds: Array; - divisions: Array<"B" | "C" | NullToken>; - buildings: string[]; - timeBlockIds: number[]; -}; const EMPTY_FILTERS: EventFilters = { search: "", categoryIds: [], + includeNoCategory: false, divisions: [], + includeNoDivision: false, buildings: [], timeBlockIds: [], }; @@ -101,102 +96,6 @@ function TabBar({ ); } -function toggleInSet(arr: T[], value: T): T[] { - return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value]; -} - -function checkboxRow(label: string, checked: boolean, onChange: () => void) { - return ( - - ); -} - -function FiltersModal({ - open, - filters, - categories, - buildings, - timeBlocks, - onChange, - onClear, - onClose, -}: { - open: boolean; - filters: EventFilters; - categories: TournamentCategory[]; - buildings: string[]; - timeBlocks: TimeBlock[]; - onChange: (next: EventFilters) => void; - onClear: () => void; - onClose: () => void; -}) { - if (!open) return null; - - return ( - <> -
-
-
-

Filters

- -
- -
-
-

Category

-
- {checkboxRow("No category", filters.categoryIds.includes(NONE), () => onChange({ ...filters, categoryIds: toggleInSet(filters.categoryIds, NONE) }))} - {categories.map((c) => ( -
- {checkboxRow(c.name, filters.categoryIds.includes(c.id), () => onChange({ ...filters, categoryIds: toggleInSet(filters.categoryIds, c.id) }))} -
- ))} -
-
- -
-

Division

-
- {checkboxRow("Division B", filters.divisions.includes("B"), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, "B") }))} - {checkboxRow("Division C", filters.divisions.includes("C"), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, "C") }))} - {checkboxRow("No division", filters.divisions.includes(NONE), () => onChange({ ...filters, divisions: toggleInSet(filters.divisions, NONE) }))} -
-
- -
-

Building

-
- {buildings.length === 0 ? No building values yet. : buildings.map((b) => ( -
- {checkboxRow(b, filters.buildings.includes(b), () => onChange({ ...filters, buildings: toggleInSet(filters.buildings, b) }))} -
- ))} -
-
- -
-

Time Blocks

-
- {timeBlocks.length === 0 ? No time blocks yet. : timeBlocks.map((b) => ( -
- {checkboxRow(`${b.label}`, filters.timeBlockIds.includes(b.id), () => onChange({ ...filters, timeBlockIds: toggleInSet(filters.timeBlockIds, b.id) }))} -
- ))} -
-
-
- -
- - -
-
- - ); -} // ─── Page ───────────────────────────────────────────────────────────────────── @@ -219,8 +118,8 @@ export default function EventsPage() { const activeFilterCount = useMemo(() => { return Number(filters.search.trim().length > 0) - + Number(filters.categoryIds.length > 0) - + Number(filters.divisions.length > 0) + + Number(filters.categoryIds.length > 0 || filters.includeNoCategory) + + Number(filters.divisions.length > 0 || filters.includeNoDivision) + Number(filters.buildings.length > 0) + Number(filters.timeBlockIds.length > 0); }, [filters]); @@ -262,8 +161,8 @@ export default function EventsPage() { const filteredEvents = useMemo(() => { const q = filters.search.trim().toLowerCase(); - const hasCategory = filters.categoryIds.length > 0; - const hasDivision = filters.divisions.length > 0; + const hasCategory = filters.categoryIds.length > 0 || filters.includeNoCategory; + const hasDivision = filters.divisions.length > 0 || filters.includeNoDivision; const hasBuilding = filters.buildings.length > 0; const hasTimeBlocks = filters.timeBlockIds.length > 0; const categorySet = new Set(filters.categoryIds); @@ -275,13 +174,15 @@ export default function EventsPage() { if (q && !e.name.toLowerCase().includes(q)) return false; if (hasCategory) { - const catVal: number | NullToken = e.category_id ?? NONE; - if (!categorySet.has(catVal)) return false; + const matchesExplicitCategory = e.category_id !== null && categorySet.has(e.category_id); + const matchesNoCategory = e.category_id === null && filters.includeNoCategory; + if (!matchesExplicitCategory && !matchesNoCategory) return false; } if (hasDivision) { - const divVal: "B" | "C" | NullToken = (e.division ?? NONE) as "B" | "C" | NullToken; - if (!divisionSet.has(divVal)) return false; + const matchesExplicitDivision = (e.division === "B" || e.division === "C") && divisionSet.has(e.division); + const matchesNoDivision = e.division === null && filters.includeNoDivision; + if (!matchesExplicitDivision && !matchesNoDivision) return false; } if (hasBuilding) { @@ -455,48 +356,25 @@ export default function EventsPage() { {activeTab !== "blocks" && (
- setFilters((f) => ({ ...f, search: e.target.value }))} placeholder="Search events..." - style={{ - width: "min(320px, 100%)", - height: "32px", - padding: "0 10px", - fontFamily: "var(--font-mono)", - fontSize: "12px", - color: "var(--color-text-primary)", - background: "var(--color-surface)", - border: "1px solid var(--color-border)", - borderRadius: "var(--radius-md)", - outline: "none", - boxSizing: "border-box", - }} + font="mono" + fullWidth + style={{ maxWidth: "320px" }} /> - + {activeFilterCount > 0 && ( - + )}
@@ -504,19 +382,13 @@ export default function EventsPage() { {isPendingTab ? " · switching..." : ""} {!selectMode && activeTab !== "timeline" && ( - + )} - +
)} @@ -611,16 +483,16 @@ export default function EventsPage() { )} - setFilters(EMPTY_FILTERS)} - onClose={() => setFiltersOpen(false)} - /> + {filtersOpen && ( + setFiltersOpen(false)} + /> + )} {/* ── Select mode floating toolbar ── */} {selectMode && ( diff --git a/frontend/components/events/EventFiltersPanel.tsx b/frontend/components/events/EventFiltersPanel.tsx new file mode 100644 index 00000000..57951b14 --- /dev/null +++ b/frontend/components/events/EventFiltersPanel.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { TimeBlock, TournamentCategory } from "@/lib/api"; +import { catColorVars } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; + +export type EventFilters = { + search: string; + categoryIds: number[]; + includeNoCategory: boolean; + divisions: Array<"B" | "C">; + includeNoDivision: boolean; + buildings: string[]; + timeBlockIds: number[]; +}; + +interface Props { + filters: EventFilters; + categories: TournamentCategory[]; + buildingOptions: string[]; + timeBlocks: TimeBlock[]; + onApply: (next: EventFilters) => void; + onClose: () => void; +} + +function tagStyle(active: boolean, bg: string, color: string, border: string): React.CSSProperties { + return { + height: "30px", + padding: "0 10px", + borderRadius: "999px", + border: `1px solid ${border}`, + background: active ? bg : "var(--color-surface)", + color: active ? color : "var(--color-text-secondary)", + fontFamily: "var(--font-sans)", + fontSize: "12px", + cursor: "pointer", + whiteSpace: "nowrap", + }; +} + +function selectedChip(label: string, onRemove: () => void): React.ReactNode { + return ( + + {label} + + + ); +} + +export function EventFiltersPanel({ + filters, + categories, + buildingOptions, + timeBlocks, + onApply, + onClose, +}: Props) { + const [draft, setDraft] = useState(filters); + const [buildingQuery, setBuildingQuery] = useState(""); + const [blockQuery, setBlockQuery] = useState(""); + + const blockLabelById = useMemo(() => { + const map = new Map(); + for (const b of timeBlocks) map.set(b.id, b.label); + return map; + }, [timeBlocks]); + + const buildingSuggestions = useMemo(() => { + const q = buildingQuery.trim().toLowerCase(); + const selected = new Set(draft.buildings); + const base = q + ? buildingOptions.filter((b) => b.toLowerCase().includes(q)) + : buildingOptions; + return base.filter((b) => !selected.has(b)).slice(0, 8); + }, [buildingQuery, buildingOptions, draft.buildings]); + + const blockSuggestions = useMemo(() => { + const q = blockQuery.trim().toLowerCase(); + const selected = new Set(draft.timeBlockIds); + return timeBlocks + .filter((b) => !selected.has(b.id)) + .filter((b) => (q ? b.label.toLowerCase().includes(q) : true)) + .slice(0, 8); + }, [blockQuery, timeBlocks, draft.timeBlockIds]); + + const addBuilding = (value: string) => { + const cleaned = value.trim(); + if (!cleaned) return; + if (draft.buildings.includes(cleaned)) return; + setDraft((d) => ({ ...d, buildings: [...d.buildings, cleaned] })); + setBuildingQuery(""); + }; + + const addBlock = (id: number) => { + if (draft.timeBlockIds.includes(id)) return; + setDraft((d) => ({ ...d, timeBlockIds: [...d.timeBlockIds, id] })); + setBlockQuery(""); + }; + + const clearAll = () => + setDraft({ + search: draft.search, + categoryIds: [], + includeNoCategory: false, + divisions: [], + includeNoDivision: false, + buildings: [], + timeBlockIds: [], + }); + + return ( + <> +
+
+
+

Filters

+ +
+ +
+
+
Category
+
+ {categories.map((cat, idx) => { + const cv = catColorVars(idx); + const active = draft.categoryIds.includes(cat.id); + return ( + + ); + })} + +
+
+ +
+
Division
+
+ + + +
+
+ +
+
Building
+ setBuildingQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (buildingSuggestions.length > 0) addBuilding(buildingSuggestions[0]); + else addBuilding(buildingQuery); + } + }} + placeholder="Type building name..." + fullWidth + font="mono" + /> + {buildingSuggestions.length > 0 && ( +
+ {buildingSuggestions.map((b) => ( + + ))} +
+ )} + {draft.buildings.length > 0 && ( +
+ {draft.buildings.map((b) => ( + {selectedChip(b, () => setDraft((d) => ({ ...d, buildings: d.buildings.filter((x) => x !== b) })))} + ))} +
+ )} +
+ +
+
Time blocks
+ setBlockQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (blockSuggestions.length > 0) addBlock(blockSuggestions[0].id); + } + }} + placeholder="Search block label..." + fullWidth + font="mono" + /> + {blockSuggestions.length > 0 && ( +
+ {blockSuggestions.map((b) => ( + + ))} +
+ )} + {draft.timeBlockIds.length > 0 && ( +
+ {draft.timeBlockIds.map((id) => ( + + {selectedChip(blockLabelById.get(id) ?? `#${id}`, () => setDraft((d) => ({ ...d, timeBlockIds: d.timeBlockIds.filter((x) => x !== id) })))} + + ))} +
+ )} +
+
+ +
+ +
+ + +
+
+
+ + ); +} diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx index 15a04b40..3b5e85f0 100644 --- a/frontend/components/events/EventTimeline.tsx +++ b/frontend/components/events/EventTimeline.tsx @@ -171,6 +171,17 @@ function buildDateGroups(timeBlocks: TimeBlock[]): DateGroup[] { return groups; } +function withDateOffsets(dateGroups: DateGroup[], colW: number): Array { + let start = 0; + const rows: Array = []; + for (const dg of dateGroups) { + const width = dg.blockCount * colW; + rows.push({ ...dg, start, width }); + start += width; + } + return rows; +} + // ─── Component ──────────────────────────────────────────────────────────────── export function EventTimeline({ events, timeBlocks, categories, onEventClick, onAddClick }: Props) { @@ -223,15 +234,7 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on ); const groups = useMemo(() => buildGroups(scheduled, categories, groupBy), [scheduled, categories, groupBy]); const dateGroups = useMemo(() => buildDateGroups(timeBlocks), [timeBlocks]); - const dateGroupsWithOffsets = useMemo(() => { - let start = 0; - return dateGroups.map((dg) => { - const width = dg.blockCount * colW; - const item = { ...dg, start, width }; - start += width; - return item; - }); - }, [dateGroups, colW]); + const dateGroupsWithOffsets = withDateOffsets(dateGroups, colW); const columnLayout = useMemo(() => resolveColumnLayout(timeBlocks, colW), [timeBlocks, colW]); useEffect(() => { @@ -347,16 +350,6 @@ export function EventTimeline({ events, timeBlocks, categories, onEventClick, on - -
- - - {events.length} event{events.length !== 1 ? "s" : ""} - - -
); From 6c90601003d0e75f011790bdffe034e94a742d36 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 20:41:19 -0700 Subject: [PATCH 087/100] feat(events): restyle filter panel as left side panel and fix toolbar layout - Group search input + filter/clear buttons in a sub-flex container so they always stay side-by-side - Convert EventFiltersPanel from a center modal to a fixed left side panel matching EventSidePanel layout and animations - Add slide-in-from-left / fade animations with onAnimationEnd close sequencing - Change category/division tag border-radius from 999px to var(--radius-md) to match table tag style - Style selected building/timeblock chips with var(--radius-sm) and var(--font-mono) to match time block chips in table view - Make building and time block suggestion lists focus-driven dropdowns hidden until input is focused --- .../dashboard/[tournamentId]/events/page.tsx | 39 +- .../components/events/EventFiltersPanel.tsx | 446 ++++++++++++++---- 2 files changed, 379 insertions(+), 106 deletions(-) diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index ad78fd4f..1ecd4cf8 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -356,26 +356,27 @@ export default function EventsPage() { {activeTab !== "blocks" && (
- setFilters((f) => ({ ...f, search: e.target.value }))} - placeholder="Search events..." - font="mono" - fullWidth - style={{ maxWidth: "320px" }} - /> - - {activeFilterCount > 0 && ( - - )} + {activeFilterCount > 0 && ( + + )} +
{filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} diff --git a/frontend/components/events/EventFiltersPanel.tsx b/frontend/components/events/EventFiltersPanel.tsx index 57951b14..440a1419 100644 --- a/frontend/components/events/EventFiltersPanel.tsx +++ b/frontend/components/events/EventFiltersPanel.tsx @@ -4,7 +4,6 @@ import { useMemo, useState } from "react"; import { TimeBlock, TournamentCategory } from "@/lib/api"; import { catColorVars } from "@/lib/formatters"; import { Button } from "@/components/ui/Button"; -import { Input } from "@/components/ui/Input"; export type EventFilters = { search: string; @@ -27,9 +26,9 @@ interface Props { function tagStyle(active: boolean, bg: string, color: string, border: string): React.CSSProperties { return { - height: "30px", + height: "28px", padding: "0 10px", - borderRadius: "999px", + borderRadius: "var(--radius-md)", border: `1px solid ${border}`, background: active ? bg : "var(--color-surface)", color: active ? color : "var(--color-text-secondary)", @@ -40,12 +39,51 @@ function tagStyle(active: boolean, bg: string, color: string, border: string): R }; } -function selectedChip(label: string, onRemove: () => void): React.ReactNode { +function SectionLabel({ children }: { children: React.ReactNode }) { return ( - +
+ {children} +
+ ); +} + +function SelectedChip({ label, onRemove }: { label: string; onRemove: () => void }) { + return ( + {label} - ); @@ -62,6 +100,11 @@ export function EventFiltersPanel({ const [draft, setDraft] = useState(filters); const [buildingQuery, setBuildingQuery] = useState(""); const [blockQuery, setBlockQuery] = useState(""); + const [buildingOpen, setBuildingOpen] = useState(false); + const [blockOpen, setBlockOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + + const triggerClose = () => setIsClosing(true); const blockLabelById = useMemo(() => { const map = new Map(); @@ -89,16 +132,17 @@ export function EventFiltersPanel({ const addBuilding = (value: string) => { const cleaned = value.trim(); - if (!cleaned) return; - if (draft.buildings.includes(cleaned)) return; + if (!cleaned || draft.buildings.includes(cleaned)) return; setDraft((d) => ({ ...d, buildings: [...d.buildings, cleaned] })); setBuildingQuery(""); + setBuildingOpen(false); }; const addBlock = (id: number) => { if (draft.timeBlockIds.includes(id)) return; setDraft((d) => ({ ...d, timeBlockIds: [...d.timeBlockIds, id] })); setBlockQuery(""); + setBlockOpen(false); }; const clearAll = () => @@ -112,136 +156,364 @@ export function EventFiltersPanel({ timeBlockIds: [], }); + const handleApply = () => { + onApply(draft); + triggerClose(); + }; + + const dropdownList: React.CSSProperties = { + position: "absolute", + top: "calc(100% + 2px)", + left: 0, + right: 0, + zIndex: 10, + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + background: "var(--color-surface)", + boxShadow: "var(--shadow-md)", + overflow: "hidden", + maxHeight: "200px", + overflowY: "auto", + }; + + const dropdownItem: React.CSSProperties = { + display: "block", + width: "100%", + textAlign: "left", + border: "none", + borderBottom: "1px solid var(--color-border)", + background: "var(--color-surface)", + color: "var(--color-text-primary)", + fontFamily: "var(--font-mono)", + fontSize: "12px", + padding: "8px 10px", + cursor: "pointer", + }; + + const fieldInput: React.CSSProperties = { + width: "100%", + height: "36px", + padding: "0 10px", + fontFamily: "var(--font-mono)", + fontSize: "13px", + background: "var(--color-surface)", + color: "var(--color-text-primary)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + outline: "none", + boxSizing: "border-box", + transition: "border-color 150ms ease", + }; + return ( <> -
-
-
-

Filters

-
-
+ {/* Body */} +
+ + {/* Category */}
-
Category
-
+ Category +
{categories.map((cat, idx) => { const cv = catColorVars(idx); const active = draft.categoryIds.includes(cat.id); return ( - ); })} -
+ {/* Division */}
-
Division
-
- - - + ); + })} +
+ {/* Building */}
-
Building
- setBuildingQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - if (buildingSuggestions.length > 0) addBuilding(buildingSuggestions[0]); - else addBuilding(buildingQuery); + Building +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setBuildingOpen(false); } }} - placeholder="Type building name..." - fullWidth - font="mono" - /> - {buildingSuggestions.length > 0 && ( -
- {buildingSuggestions.map((b) => ( - - ))} -
- )} + > + setBuildingQuery(e.target.value)} + onFocus={() => setBuildingOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (buildingSuggestions.length > 0) addBuilding(buildingSuggestions[0]); + else addBuilding(buildingQuery); + } + if (e.key === "Escape") setBuildingOpen(false); + }} + placeholder="Type or select building..." + style={fieldInput} + onMouseEnter={(e) => { (e.target as HTMLInputElement).style.borderColor = "var(--color-border-strong)"; }} + onMouseLeave={(e) => { (e.target as HTMLInputElement).style.borderColor = "var(--color-border)"; }} + /> + {buildingOpen && buildingSuggestions.length > 0 && ( +
+ {buildingSuggestions.map((b) => ( + + ))} +
+ )} +
{draft.buildings.length > 0 && ( -
+
{draft.buildings.map((b) => ( - {selectedChip(b, () => setDraft((d) => ({ ...d, buildings: d.buildings.filter((x) => x !== b) })))} + setDraft((d) => ({ ...d, buildings: d.buildings.filter((x) => x !== b) }))} + /> ))}
)}
+ {/* Time blocks */}
-
Time blocks
- setBlockQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - if (blockSuggestions.length > 0) addBlock(blockSuggestions[0].id); + Time blocks +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setBlockOpen(false); } }} - placeholder="Search block label..." - fullWidth - font="mono" - /> - {blockSuggestions.length > 0 && ( -
- {blockSuggestions.map((b) => ( - - ))} -
- )} + > + setBlockQuery(e.target.value)} + onFocus={() => setBlockOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (blockSuggestions.length > 0) addBlock(blockSuggestions[0].id); + } + if (e.key === "Escape") setBlockOpen(false); + }} + placeholder="Type or select block..." + style={fieldInput} + onMouseEnter={(e) => { (e.target as HTMLInputElement).style.borderColor = "var(--color-border-strong)"; }} + onMouseLeave={(e) => { (e.target as HTMLInputElement).style.borderColor = "var(--color-border)"; }} + /> + {blockOpen && blockSuggestions.length > 0 && ( +
+ {blockSuggestions.map((b) => ( + + ))} +
+ )} +
{draft.timeBlockIds.length > 0 && ( -
+
{draft.timeBlockIds.map((id) => ( - - {selectedChip(blockLabelById.get(id) ?? `#${id}`, () => setDraft((d) => ({ ...d, timeBlockIds: d.timeBlockIds.filter((x) => x !== id) })))} - + setDraft((d) => ({ ...d, timeBlockIds: d.timeBlockIds.filter((x) => x !== id) }))} + /> ))}
)}
+
-
+ {/* Footer */} +
- -
From 624ac5063840abf75b2cbc6a4aee15e103d48000 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sat, 18 Apr 2026 20:55:58 -0700 Subject: [PATCH 088/100] fix(events): update filter panel inputs, chip placement, and panel direction - Match building/timeblock search input style to volunteers page (var(--font-sans), 34px height, var(--radius-md), 0 12px padding) - Move selected building and timeblock chips above their respective inputs - Change filter panel slide direction from left to right to avoid overlapping the sidebar nav - Remove redundant onMouseEnter/onMouseLeave border-color overrides from panel inputs --- .../components/events/EventFiltersPanel.tsx | 72 +++++++++---------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/frontend/components/events/EventFiltersPanel.tsx b/frontend/components/events/EventFiltersPanel.tsx index 440a1419..8e79fca9 100644 --- a/frontend/components/events/EventFiltersPanel.tsx +++ b/frontend/components/events/EventFiltersPanel.tsx @@ -44,6 +44,7 @@ function SectionLabel({ children }: { children: React.ReactNode }) {