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..27295ebf --- /dev/null +++ b/backend/alembic/versions/133d0c5a5ffe_time_blocks_categories_membership_events.py @@ -0,0 +1,277 @@ +"""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 _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 + # ------------------------------------------------------------------ + 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), + ) + + # ------------------------------------------------------------------ + # 2. Create tournament_categories + # ------------------------------------------------------------------ + 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'), + ) + + # ------------------------------------------------------------------ + # 3. Create event_time_blocks association table + # ------------------------------------------------------------------ + 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 + # ------------------------------------------------------------------ + 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 + # 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 + # ------------------------------------------------------------------ + import json + 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 + 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") + 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 + 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) + 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 + # ------------------------------------------------------------------ + 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(), 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}] + # ------------------------------------------------------------------ + 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 + 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 (batch mode required for SQLite) + # ------------------------------------------------------------------ + if _has_column(conn, 'tournaments', 'blocks'): + 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'): + 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 (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(), 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 + SET category = ( + SELECT tc.name FROM tournament_categories tc WHERE tc.id = events.category_id + ) + WHERE events.category_id IS NOT NULL + """)) + + if _has_column(conn, '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'): + 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') diff --git a/backend/app/api/routes/categories.py b/backend/app/api/routes/categories.py new file mode 100644 index 00000000..b03bfffe --- /dev/null +++ b/backend/app/api/routes/categories.py @@ -0,0 +1,133 @@ +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, TournamentCategoryUpdate +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.patch("/{cat_id}/", response_model=TournamentCategoryRead) +def update_category( + tournament_id: int, + cat_id: int, + payload: TournamentCategoryUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission(MANAGE_EVENTS)), +): + """Rename a custom category. Default categories cannot be edited.""" + 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="Default categories cannot be edited", + ) + + existing = ( + db.query(TournamentCategory) + .filter( + TournamentCategory.tournament_id == tournament_id, + TournamentCategory.name == payload.name, + TournamentCategory.id != cat_id, + ) + .first() + ) + if existing: + raise HTTPException(status_code=400, detail="Category already exists") + + db_cat.name = payload.name + 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. Default 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="Default 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..fbb10731 100644 --- a/backend/app/api/routes/events.py +++ b/backend/app/api/routes/events.py @@ -1,43 +1,23 @@ 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 from app.core.permissions import ( 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.schemas.event import EventCreate, EventRead, EventUpdate +from app.models.models import Event, Tournament, User, TimeBlock, TournamentCategory +from app.schemas.event import EventCreate, EventRead, EventUpdate, EventBatchUpdate # Routes are nested: /tournaments/{tournament_id}/events/... # tournament_id is always present in the path, which drives the permission check. router = APIRouter(prefix="/tournaments/{tournament_id}/events", tags=["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. @@ -52,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", @@ -69,21 +46,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 +76,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 +113,71 @@ 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 + + +# --------------------------------------------------------------------------- +# PATCH /tournaments/{tournament_id}/events/batch/ — manage_events or manage_tournament +# --------------------------------------------------------------------------- +@router.patch("/batch/", response_model=list[EventRead]) +def batch_update_events( + tournament_id: int, + payload: EventBatchUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Apply a partial update to multiple events in one request. + + Only keys present in `payload.updates` are written; absent keys are skipped. + Returns the updated EventRead list in the same order as event_ids. + """ + _require_write_permission(current_user, tournament_id, db) + + update_data = payload.updates.model_dump(exclude_none=True) + time_block_ids = update_data.pop("time_block_ids", None) + + blocks = None + if time_block_ids is not None: + blocks = ( + db.query(TimeBlock) + .filter( + TimeBlock.id.in_(time_block_ids), + TimeBlock.tournament_id == tournament_id, + ) + .all() + ) + + updated: list[Event] = [] + for event_id in payload.event_ids: + event = _get_event_or_404(event_id, tournament_id, db) + for field, value in update_data.items(): + setattr(event, field, value) + if blocks is not None: + event.time_blocks = blocks + updated.append(event) + + db.commit() + for event in updated: + db.refresh(event) + return updated # --------------------------------------------------------------------------- @@ -154,12 +194,23 @@ 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(): - 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 = ( + 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 +226,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/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/api/routes/time_blocks.py b/backend/app/api/routes/time_blocks.py new file mode 100644 index 00000000..e8d6ef3f --- /dev/null +++ b/backend/app/api/routes/time_blocks.py @@ -0,0 +1,100 @@ +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, 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/config.py b/backend/app/core/config.py index 940957fe..1fa00b3c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -20,6 +20,12 @@ class Settings(BaseSettings): # Must be set to a long random string in production — never commit the real value jwt_secret: str = "dev-secret-change-in-production" + # Dev seed toggles — only apply when app_env is "development" or "preview" + seed_users: bool = True + seed_tournament: bool = True + seed_time_blocks: bool = True + seed_events: bool = True + @lru_cache() def get_settings() -> Settings: diff --git a/backend/app/core/permissions.py b/backend/app/core/permissions.py index 36b03e0d..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", @@ -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/db/init_db.py b/backend/app/db/init_db.py index f989a767..4ba77703 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -16,34 +16,34 @@ def init_db() -> None: - """Create all tables defined on Base metadata.""" - Base.metadata.create_all(bind=engine) - print("✓ Database tables created.") + """Run all pending Alembic migrations (upgrade to head). + Safe to call on every startup — Alembic is idempotent and will + no-op if the database is already at the latest revision. + """ + from alembic.config import Config + from alembic import command + + alembic_cfg = Config("alembic.ini") + command.upgrade(alembic_cfg, "head") + print("✓ Database migrated to head.") -def seed_dev_data(db: Session) -> None: + +def seed_users(db: Session) -> None: """ - Seed dev database with: + Seed dev users: - 1 admin account (role="admin") - - 1 regular user account (role="user", tournament_director membership) - - 1 sample tournament owned by the regular user - - TD membership for the regular user (positions=["tournament_director"]) - - Volunteer membership for the admin (positions=["event_supervisor"]) - — demonstrates that admin can also hold a per-tournament membership + - 1 regular user account (role="user") Idempotent — skips if admin already exists. """ - from app.models.models import Membership, Tournament, User + from app.models.models import User from app.core.auth import hash_password - from app.core.permissions import DEFAULT_POSITIONS - from datetime import datetime - # Skip if already seeded if db.query(User).filter(User.email == "admin@nexus.dev").first(): - print("✓ Dev seed already exists, skipping.") + print("✓ Dev users already exist, skipping.") return - # Admin account — full site-wide access, bypasses all tournament checks. admin = User( email="admin@nexus.dev", hashed_password=hash_password("admin1234"), @@ -54,9 +54,6 @@ def seed_dev_data(db: Session) -> None: ) db.add(admin) - # Regular user account — tournament access determined by membership positions. - # Previously "td@nexus.dev" with role="td"; now role="user" with a - # tournament_director membership on the sample tournament. td = User( email="td@nexus.dev", hashed_password=hash_password("td1234"), @@ -66,59 +63,219 @@ def seed_dev_data(db: Session) -> None: is_active=True, ) db.add(td) - db.flush() # get IDs before creating tournament + memberships - # Sample tournament owned by the regular user + demo = User( + email="demo@nexus.dev", + hashed_password=hash_password("demoUsEr328563010"), + first_name="Demo", + last_name="User", + role="user", + is_active=True, + ) + db.add(demo) + db.commit() + + print("✓ Seeded: admin@nexus.dev / admin1234 (role=admin)") + print("✓ Seeded: td@nexus.dev / td1234 (role=user)") + + +def seed_tournament(db: Session) -> None: + """ + Seed the sample tournament, default categories, and memberships. + + Idempotent — skips if the tournament already exists. + Requires seed_users to have run first. + """ + from app.models.models import Membership, Tournament, TournamentCategory, User + from app.core.permissions import DEFAULT_POSITIONS, DEFAULT_CATEGORIES + from datetime import datetime + + if db.query(Tournament).filter(Tournament.name == "2026 National Tournament @ USC").first(): + print("✓ Dev tournament already exists, skipping.") + return + + td = db.query(User).filter(User.email == "td@nexus.dev").first() + admin = db.query(User).filter(User.email == "admin@nexus.dev").first() + if not td or not admin: + print("✗ seed_tournament requires seed_users to run first — skipping.") + return + tournament = Tournament( name="2026 National Tournament @ USC", start_date=datetime(2026, 5, 21, 8, 0), 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() - # TD membership for the regular user — full manage_tournament access - td_membership = Membership( + for cat_name in DEFAULT_CATEGORIES: + db.add(TournamentCategory( + tournament_id=tournament.id, + name=cat_name, + is_custom=False, + )) + + db.add(Membership( user_id=td.id, tournament_id=tournament.id, positions=["tournament_director"], status="confirmed", - ) - db.add(td_membership) - - # Volunteer membership for admin — demonstrates cross-role scenario: - # admin has site-wide access AND a volunteer-level membership here - admin_membership = Membership( + )) + db.add(Membership( user_id=admin.id, tournament_id=tournament.id, positions=["event_supervisor"], status="confirmed", - ) - db.add(admin_membership) - + )) db.commit() - print("✓ Seeded: admin@nexus.dev / admin1234 (role=admin, event_supervisor in sample tournament)") - print("✓ Seeded: td@nexus.dev / td1234 (role=user, tournament_director in sample tournament)") print(f"✓ Seeded tournament: '{tournament.name}'") +def seed_time_blocks(db: Session) -> None: + """ + Seed sample time blocks for the dev tournament. + + Idempotent — skips if any time blocks already exist for the tournament. + Requires seed_tournament to have run first. + """ + from app.models.models import TimeBlock, Tournament + + tournament = db.query(Tournament).filter(Tournament.name == "2026 National Tournament @ USC").first() + if not tournament: + print("✗ seed_time_blocks requires seed_tournament to run first — skipping.") + return + + if db.query(TimeBlock).filter(TimeBlock.tournament_id == tournament.id).first(): + print("✓ Dev time blocks already exist, skipping.") + return + + 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, + )) + db.commit() + + print(f"✓ Seeded {len(sample_blocks)} time blocks.") + + +def seed_events(db: Session) -> None: + """ + Seed 2026 Science Olympiad events for the dev tournament. + + Idempotent — skips if any events already exist for the tournament. + Requires seed_tournament to have run first. + """ + from app.models.models import Event, Tournament, TournamentCategory + + tournament = db.query(Tournament).filter(Tournament.name == "2026 National Tournament @ USC").first() + if not tournament: + print("✗ seed_events requires seed_tournament to run first — skipping.") + return + + from app.models.models import Event as EventModel + if db.query(EventModel).filter(EventModel.tournament_id == tournament.id).first(): + print("✓ Dev events already exist, skipping.") + return + + cat_map = { + cat.name: cat.id + for cat in db.query(TournamentCategory).filter_by(tournament_id=tournament.id).all() + } + + LIFE = "Life, Personal & Social Science" + EARTH = "Earth and Space Science" + PHYS = "Physical Science & Chemistry" + TECH = "Technology & Engineering" + INQ = "Inquiry & Nature of Science" + + events_div_b = [ + ("Anatomy & Physiology", "B", LIFE), + ("Astronomy", "B", EARTH), + ("Bungee Drop", "B", TECH), + ("Chemistry Lab", "B", PHYS), + ("Code Busters", "B", TECH), + ("Crime Busters", "B", INQ), + ("Disease Detectives", "B", LIFE), + ("Dynamic Planet", "B", EARTH), + ("Ecology", "B", LIFE), + ("Electric Vehicle", "B", TECH), + ("Food Science", "B", PHYS), + ("Forestry", "B", LIFE), + ("Geologic Mapping", "B", EARTH), + ("Helicopters", "B", TECH), + ("Microbe Mission", "B", LIFE), + ("Mouse Trap Vehicle", "B", TECH), + ("Optics", "B", PHYS), + ("Ping Pong Parachute", "B", TECH), + ("Rocks & Minerals", "B", EARTH), + ("Scrambler", "B", TECH), + ("Solar System", "B", EARTH), + ("Towers", "B", TECH), + ("Write It Do It", "B", INQ), + ] + + events_div_c = [ + ("Anatomy & Physiology", "C", LIFE), + ("Astronomy", "C", EARTH), + ("Bungee Drop", "C", TECH), + ("Chemistry Lab", "C", PHYS), + ("Code Busters", "C", TECH), + ("Disease Detectives", "C", LIFE), + ("Dynamic Planet", "C", EARTH), + ("Ecology", "C", LIFE), + ("Electric Vehicle", "C", TECH), + ("Experimental Design", "C", INQ), + ("Fermi Questions", "C", INQ), + ("Flight", "C", TECH), + ("Forensics", "C", PHYS), + ("Geologic Mapping", "C", EARTH), + ("Helicopters", "C", TECH), + ("Materials Science", "C", PHYS), + ("Microbe Mission", "C", LIFE), + ("Optics", "C", PHYS), + ("Remote Sensing", "C", EARTH), + ("Rocks & Minerals", "C", EARTH), + ("Scrambler", "C", TECH), + ("Towers", "C", TECH), + ("Write It Do It", "C", INQ), + ] + + all_events = events_div_b + events_div_c + for name, division, cat_name in all_events: + db.add(Event( + tournament_id=tournament.id, + name=name, + division=division, + event_type="standard", + category_id=cat_map.get(cat_name), + volunteers_needed=2, + )) + db.commit() + + print(f"✓ Seeded {len(all_events)} events.") + + if __name__ == "__main__": from app.db.session import SessionLocal @@ -126,4 +283,11 @@ def seed_dev_data(db: Session) -> None: if settings.app_env in ("development", "preview"): with SessionLocal() as db: - seed_dev_data(db) \ No newline at end of file + if settings.seed_users: + seed_users(db) + if settings.seed_tournament: + seed_tournament(db) + if settings.seed_time_blocks: + seed_time_blocks(db) + if settings.seed_events: + seed_events(db) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py index c82b08f2..b98e3ff5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,9 +5,9 @@ from app.core.config import get_settings from app.core.security import verify_api_key -from app.db.init_db import init_db, seed_dev_data +from app.db.init_db import init_db, seed_users, seed_tournament, seed_time_blocks, seed_events 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() @@ -20,7 +20,14 @@ async def lifespan(app: FastAPI): if get_settings().app_env in ("development", "preview"): from app.db.session import SessionLocal with SessionLocal() as db: - seed_dev_data(db) + if settings.seed_users: + seed_users(db) + if settings.seed_tournament: + seed_tournament(db) + if settings.seed_time_blocks: + seed_time_blocks(db) + if settings.seed_events: + seed_events(db) yield @@ -52,6 +59,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 80e6d1d6..a1e952c8 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -35,9 +35,6 @@ class Tournament(Base): end_date = Column(DateTime(timezone=True), 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"), + ) # --------------------------------------------------------------------------- @@ -124,7 +188,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. @@ -144,18 +208,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 @@ -191,7 +251,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 @@ -209,6 +271,20 @@ class Membership(Base): volunteering_exp = Column(Text, nullable=True) +# --------------------------------------------------------------------------- +# [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 # --------------------------------------------------------------------------- @@ -244,19 +320,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(timezone=True), default=utcnow) - updated_at = Column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) + + 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..805b35b4 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,25 @@ 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 EventBatchUpdate(BaseModel): + """Batch-update a set of events. Only keys present in `updates` are applied.""" + event_ids: list[int] + updates: EventUpdate 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/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/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..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,14 +104,15 @@ 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 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..f9e21da2 --- /dev/null +++ b/backend/app/schemas/tournament_category.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from datetime import datetime +from pydantic import BaseModel + + +class TournamentCategoryBase(BaseModel): + name: str + + +class TournamentCategoryCreate(TournamentCategoryBase): + pass + + +class TournamentCategoryUpdate(BaseModel): + name: str + + +class TournamentCategoryRead(TournamentCategoryBase): + id: int + tournament_id: int + is_custom: bool + created_at: datetime + + model_config = {"from_attributes": True} 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_categories.py b/backend/tests/api/test_categories.py new file mode 100644 index 00000000..0f10be66 --- /dev/null +++ b/backend/tests/api/test_categories.py @@ -0,0 +1,281 @@ +"""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 _update_category(client, tournament_id, cat_id, name): + return client.patch( + f"/tournaments/{tournament_id}/categories/{cat_id}/", 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 + + +# --------------------------------------------------------------------------- +# Update +# --------------------------------------------------------------------------- + +def test_update_custom_category(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + created = _make_category(client, td_tournament.id, name="Robotics").json() + response = _update_category(client, td_tournament.id, created["id"], "Engineering") + assert response.status_code == 200 + data = response.json() + assert data["id"] == created["id"] + assert data["name"] == "Engineering" + assert data["is_custom"] is True + + +def test_update_default_category_forbidden(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + categories = client.get(f"/tournaments/{td_tournament.id}/categories/").json() + default_cat = next(c for c in categories if not c["is_custom"]) + response = _update_category(client, td_tournament.id, default_cat["id"], "Renamed") + assert response.status_code == 403 + assert response.json()["detail"] == "Default categories cannot be edited" + + +def test_update_category_duplicate_name_rejected(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + cat_a = _make_category(client, td_tournament.id, name="Robotics").json() + _make_category(client, td_tournament.id, name="Engineering") + response = _update_category(client, td_tournament.id, cat_a["id"], "Engineering") + assert response.status_code == 400 + assert response.json()["detail"] == "Category already exists" + + +def test_update_category_not_found(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = _update_category(client, td_tournament.id, 9999, "Renamed") + assert response.status_code == 404 + assert response.json()["detail"] == "Category not found" + + +def test_update_category_wrong_tournament_404( + client, td_user, td_tournament, 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() + response = _update_category(client, other_tournament.id, cat["id"], "Renamed") + assert response.status_code == 404 + + +def test_update_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") + response = _update_category(client, other_tournament.id, 1, "Renamed") + assert response.status_code == 403 + + +def test_update_category_unauthenticated(client, td_tournament): + response = _update_category(client, td_tournament.id, 1, "Renamed") + assert response.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/api/test_events.py b/backend/tests/api/test_events.py index e601bccc..f99e7d35 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 @@ -72,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, @@ -83,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 @@ -111,6 +197,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 +301,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 +380,113 @@ 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 + + +# --------------------------------------------------------------------------- +# Batch PATCH +# --------------------------------------------------------------------------- + +def test_batch_update_scalar_fields(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + e1 = _make_event(client, td_tournament.id, name="Anatomy", division="B").json() + e2 = _make_event(client, td_tournament.id, name="Boomilever", division="C").json() + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [e1["id"], e2["id"]], "updates": {"division": "B"}}, + ) + assert response.status_code == 200 + assert all(e["division"] == "B" for e in response.json()) + + +def test_batch_update_time_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + e1 = _make_event(client, td_tournament.id, name="Anatomy", division="B").json() + e2 = _make_event(client, td_tournament.id, name="Boomilever", division="C").json() + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [e1["id"], e2["id"]], "updates": {"time_block_ids": [block["id"]]}}, + ) + assert response.status_code == 200 + for e in response.json(): + assert len(e["time_blocks"]) == 1 + assert e["time_blocks"][0]["id"] == block["id"] + + +def test_batch_update_clear_time_blocks(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + block = _make_block(client, td_tournament.id).json() + e1 = _make_event(client, td_tournament.id, name="Anatomy", division="B", time_block_ids=[block["id"]]).json() + e2 = _make_event(client, td_tournament.id, name="Boomilever", division="C", time_block_ids=[block["id"]]).json() + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [e1["id"], e2["id"]], "updates": {"time_block_ids": []}}, + ) + assert response.status_code == 200 + assert all(e["time_blocks"] == [] for e in response.json()) + + +def test_batch_update_absent_keys_not_applied(client, td_user, td_tournament): + """Fields absent from updates must not be modified.""" + login(client, "td@test.com", "tdpass") + e1 = _make_event(client, td_tournament.id, name="Anatomy", division="B", building="Hall A").json() + e2 = _make_event(client, td_tournament.id, name="Boomilever", division="C", building="Hall B").json() + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [e1["id"], e2["id"]], "updates": {"division": "B"}}, + ) + assert response.status_code == 200 + buildings = {e["id"]: e["building"] for e in response.json()} + assert buildings[e1["id"]] == "Hall A" + assert buildings[e2["id"]] == "Hall B" + + +def test_batch_update_empty_event_ids(client, td_user, td_tournament): + login(client, "td@test.com", "tdpass") + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [], "updates": {"division": "C"}}, + ) + assert response.status_code == 200 + assert response.json() == [] + + +def test_batch_update_event_from_other_tournament_returns_404( + client, td_user, td_tournament, other_user, other_tournament, db +): + 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_event = _make_event(client, other_tournament.id, name="Anatomy", division="B").json() + + response = client.patch( + f"/tournaments/{td_tournament.id}/events/batch/", + json={"event_ids": [other_event["id"]], "updates": {"division": "C"}}, + ) + assert response.status_code == 404 + + +def test_batch_update_forbidden_for_non_manager(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") + response = client.patch( + f"/tournaments/{other_tournament.id}/events/batch/", + json={"event_ids": [], "updates": {"division": "C"}}, + ) + assert response.status_code == 403 diff --git a/backend/tests/api/test_memberships.py b/backend/tests/api/test_memberships.py index b586271a..75853ac9 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, label="Block 1", start="09:00", end="11:00"): + return client.post(f"/tournaments/{tournament_id}/blocks/", json={ + "label": label, "date": "2025-03-15", "start": start, "end": end, }).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, 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=[ - {"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, 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']}/", 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): 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() diff --git a/backend/tests/api/test_time_blocks.py b/backend/tests/api/test_time_blocks.py new file mode 100644 index 00000000..15233050 --- /dev/null +++ b/backend/tests/api/test_time_blocks.py @@ -0,0 +1,262 @@ +"""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 + + +# --------------------------------------------------------------------------- +# Additional time behavior +# --------------------------------------------------------------------------- + + +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_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 + + 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 = [ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index d806aa86..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, - blocks=[], 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, - blocks=[], 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, 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 diff --git a/frontend/app/dashboard/[tournamentId]/events/page.tsx b/frontend/app/dashboard/[tournamentId]/events/page.tsx index 494b3a42..2a1e6828 100644 --- a/frontend/app/dashboard/[tournamentId]/events/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/events/page.tsx @@ -1,12 +1,633 @@ -// events/page.tsx "use client"; + +import { useEffect, useState, useCallback, useMemo, useTransition } from "react"; +import { useParams } from "next/navigation"; +import { + eventsApi, + timeBlocksApi, + categoriesApi, + ApiError, + Event, + EventCreate, + TimeBlock, + TimeBlockCreate, + TournamentCategory, +} from "@/lib/api"; +import { PageHeader } from "@/components/ui/PageHeader"; +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"; +import { EventCardGrid } from "@/components/events/EventCardGrid"; +import { EventTable } from "@/components/events/EventTable"; +import { EventTimeline } from "@/components/events/EventTimeline"; +import { CategoriesTable } from "@/components/events/CategoriesTable"; +import { EventFiltersPanel, EventFilters } from "@/components/events/EventFiltersPanel"; +import { Button } from "@/components/ui/Button"; +import { IconPlus } from "@/components/ui/Icons"; + +// ─── Tab type ───────────────────────────────────────────────────────────────── + +type Tab = "timeline" | "cards" | "table" | "blocks" | "categories"; + +const EMPTY_FILTERS: EventFilters = { + search: "", + categoryIds: [], + includeNoCategory: false, + divisions: [], + includeNoDivision: false, + buildings: [], + timeBlockIds: [], +}; + +// ─── 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: "table", label: "Table" }, + { id: "blocks", label: "Time Blocks" }, + { id: "categories", label: "Categories" }, + ]; + + return ( +
+ {tabs.map((t) => { + const isActive = t.id === active; + return ( + + ); + })} +
+ ); +} + + +// ─── Page ───────────────────────────────────────────────────────────────────── + export default function EventsPage() { + const params = useParams(); + 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([]); + const [timeBlocks, setTimeBlocks] = useState([]); + const [categories, setCategories] = useState([]); + 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 || filters.includeNoCategory) + + Number(filters.divisions.length > 0 || filters.includeNoDivision) + + Number(filters.buildings.length > 0) + + Number(filters.timeBlockIds.length > 0); + }, [filters]); + + const loadAll = useCallback(async (silent = false) => { + if (!silent) setLoading(true); + setError(null); + try { + 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 { + if (!silent) setLoading(false); + } + }, [tournamentId]); + + 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 || 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); + 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 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 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) { + 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) => { + if (prev?.type !== "edit") return prev; + const updated = events.find((e) => e.id === prev.event.id); + return updated ? { type: "edit", event: updated } : prev; + }); + }, [events]); + + // ── Time block handlers ─────────────────────────────────────────────────── + + const handleAddBlock = async (data: TimeBlockCreate) => { + await timeBlocksApi.create(tournamentId, data); + await loadAll(true); + }; + + const handleEditBlock = async (id: number, data: Partial) => { + await timeBlocksApi.update(tournamentId, id, data); + await loadAll(true); + }; + + // ── Delete block (with 409 modal guard) ────────────────────────────────── + + // ── Event side panel ───────────────────────────────────────────────────── + + 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 + 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: Omit) => { + if (panel?.type === "edit") { + const updated = await eventsApi.update(tournamentId, panel.event.id, data); + patchEventsState(updated); + } else { + await eventsApi.create(tournamentId, { ...data, tournament_id: tournamentId }); + await loadAll(true); + } + }; + + const handleMultiSave = async (data: Partial>) => { + if (panel?.type !== "multi-edit") return; + await eventsApi.batchUpdate(tournamentId, panel.ids, data); + await loadAll(true); + // select mode intentionally stays active after save + }; + + const handleUpdateEvent = async (id: number, delta: Partial) => { + const updated = await eventsApi.update(tournamentId, id, delta); + patchEventsState(updated); + }; + + const handleCreateCategory = async (name: string) => { + const cat = await categoriesApi.create(tournamentId, name); + await loadAll(true); + return cat; + }; + + // ── Select mode ─────────────────────────────────────────────────────────── + + 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); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + const handleSelectAll = () => setSelectedIds(new Set(filteredIds)); + + // ── Delete block ────────────────────────────────────────────────────────── + + const [deleteTarget, setDeleteTarget] = useState(null); + const [affectedEvents, setAffectedEvents] = useState([]); + + const handleDeleteClick = async (block: TimeBlock) => { + try { + await timeBlocksApi.delete(tournamentId, block.id); + await loadAll(true); + } catch (e) { + if (e instanceof ApiError && e.status === 409) { + const detail = e.detail as { affected_events?: AffectedEvent[] }; + setAffectedEvents(detail?.affected_events ?? []); + setDeleteTarget(block); + } else { + throw e; + } + } + }; + + const handleDeleteConfirm = async () => { + if (!deleteTarget) return; + await timeBlocksApi.delete(tournamentId, deleteTarget.id, true); + setDeleteTarget(null); + setAffectedEvents([]); + await loadAll(true); + }; + + // ── Render ──────────────────────────────────────────────────────────────── + return ( -
-

Events

-

- Events table — coming soon. -

+
+ + + loadAll(true)} + /> + + + + {(activeTab === "timeline" || activeTab === "cards" || activeTab === "table") && ( +
+
+ setFilters((f) => ({ ...f, search: e.target.value }))} + placeholder="Search events..." + style={{ + height: "34px", + padding: "0 12px", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + fontFamily: "var(--font-sans)", + fontSize: "13px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + outline: "none", + width: "240px", + }} + /> + + {activeFilterCount > 0 && ( + + )} +
+
+ + {filteredEvents.length} event{filteredEvents.length !== 1 ? "s" : ""} + {isPendingTab ? " · switching..." : ""} + + {!selectMode && activeTab !== "timeline" && ( + + )} + +
+ )} + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading… +
+ ) : ( + <> + {visitedTabs.has("timeline") && ( +
+ setPanel({ type: "add" })} + /> +
+ )} + + {visitedTabs.has("cards") && ( +
+ setPanel({ type: "add" })} + hideFilters + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} + /> +
+ )} + + {visitedTabs.has("table") && ( +
+ setPanel({ type: "add" })} + hideFilters + selectMode={selectMode} + selectedIds={selectedIds} + onToggleSelect={handleToggleSelect} + onEnterSelectMode={handleEnterSelectMode} + /> +
+ )} + + {activeTab === "blocks" && ( + + )} + + {activeTab === "categories" && ( + { + await categoriesApi.create(tournamentId, name); + await loadAll(true); + }} + onEdit={async (category, name) => { + await categoriesApi.update(tournamentId, category.id, name); + await loadAll(true); + }} + onDelete={async (category) => { + await categoriesApi.delete(tournamentId, category.id); + await loadAll(true); + }} + /> + )} + + )} + + {filtersOpen && ( + setFiltersOpen(false)} + /> + )} + + {/* ── Select mode floating toolbar ── */} + {selectMode && ( +
+ + {selectedIds.size} selected + +
+ +
+ +
+ +
+ )} + + {/* ── Event side panel ── */} + {panel && ( + setPanel(null)} + /> + )} + + {/* ── Delete block modal ── */} + {deleteTarget && ( + { setDeleteTarget(null); setAffectedEvents([]); }} + /> + )}
); -} \ No newline at end of file +} diff --git a/frontend/app/dashboard/[tournamentId]/sheets/[configId]/edit/page.tsx b/frontend/app/dashboard/[tournamentId]/sheets/[configId]/edit/page.tsx index 04d041cc..77eca803 100644 --- a/frontend/app/dashboard/[tournamentId]/sheets/[configId]/edit/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/sheets/[configId]/edit/page.tsx @@ -21,7 +21,7 @@ import { ImportSummaryModal } from "@/components/ui/ImportSummaryModal"; import { Input } from "@/components/ui/Input"; import { Select } from "@/components/ui/Select"; import { FieldLabel } from "@/components/ui/FieldLabel"; -import { IconArrowLeft, IconCheckCircle } from "@/components/ui/Icons"; +import { IconArrowLeft, IconCheckCircle, IconUpload } from "@/components/ui/Icons"; import { StatCard } from "@/components/ui/StatCard"; import { useSheetValidation } from "@/lib/useSheetValidation"; import { SheetMappingValidationWarningsModal, SheetMappingValidationErrorsModal } from "@/components/ui/SheetMappingValidationModals"; @@ -529,7 +529,7 @@ export default function EditSheetPage() { Fetching headers… )}
- +
diff --git a/frontend/app/dashboard/[tournamentId]/sheets/new/page.tsx b/frontend/app/dashboard/[tournamentId]/sheets/new/page.tsx index 0fdfdfa1..18d8dbe5 100644 --- a/frontend/app/dashboard/[tournamentId]/sheets/new/page.tsx +++ b/frontend/app/dashboard/[tournamentId]/sheets/new/page.tsx @@ -15,7 +15,7 @@ import { makeRichRow, SheetConfigMappingTable, } from "@/components/ui/SheetConfigMappingTable"; -import { IconArrowLeft, IconCheckCircle, IconWarning } from "@/components/ui/Icons"; +import { IconArrowLeft, IconCheckCircle, IconWarning, IconUpload } from "@/components/ui/Icons"; import { Button } from "@/components/ui/Button"; import { Banner } from "@/components/ui/Banner"; import { ImportSummaryModal } from "@/components/ui/ImportSummaryModal"; @@ -589,7 +589,7 @@ export default function NewSheetPage() { )}
- +
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 */} 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) diff --git a/frontend/components/events/CategoriesTable.tsx b/frontend/components/events/CategoriesTable.tsx new file mode 100644 index 00000000..665b4f92 --- /dev/null +++ b/frontend/components/events/CategoriesTable.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Event, TournamentCategory } from "@/lib/api"; +import { parseApiError } from "@/lib/errors"; +import { Button } from "@/components/ui/Button"; +import { IconEdit, IconPlus, IconTrash } from "@/components/ui/Icons"; + +interface Props { + categories: TournamentCategory[]; + events: Event[]; + isReadOnly?: boolean; + onAdd: (name: string) => Promise; + onEdit: (category: TournamentCategory, name: string) => Promise; + onDelete: (category: TournamentCategory) => Promise; +} + +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", +}; + +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%", +}; + +export function CategoriesTable({ + categories, + events, + isReadOnly = false, + onAdd, + onEdit, + onDelete, +}: Props) { + const [showAddRow, setShowAddRow] = useState(false); + const [name, setName] = useState(""); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const usageCount = useMemo(() => { + const map = new Map(); + for (const c of categories) map.set(c.id, 0); + for (const e of events) { + if (e.category_id != null) map.set(e.category_id, (map.get(e.category_id) ?? 0) + 1); + } + return map; + }, [categories, events]); + + const handleAdd = async () => { + const trimmed = name.trim(); + if (!trimmed) return; + setSaving(true); + setError(null); + try { + await onAdd(trimmed); + setName(""); + setShowAddRow(false); + } catch (e) { + setError(parseApiError(e)); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (category: TournamentCategory) => { + setSaving(true); + setError(null); + try { + await onDelete(category); + } catch (e) { + setError(parseApiError(e)); + } finally { + setSaving(false); + } + }; + + const handleEditStart = (category: TournamentCategory) => { + setShowAddRow(false); + setError(null); + setEditingId(category.id); + setEditName(category.name); + }; + + const handleEditSave = async (category: TournamentCategory) => { + const trimmed = editName.trim(); + if (!trimmed || trimmed === category.name) { + setEditingId(null); + return; + } + setSaving(true); + setError(null); + try { + await onEdit(category, trimmed); + setEditingId(null); + setEditName(""); + } catch (e) { + setError(parseApiError(e)); + } finally { + setSaving(false); + } + }; + + return ( +
+
+ + {categories.length} categor{categories.length === 1 ? "y" : "ies"} + + {!isReadOnly && ( + + )} +
+ + {categories.length === 0 && !showAddRow ? ( +
+

+ No categories yet +

+

+ {isReadOnly ? "No categories are configured." : "Add a category to organize events."} +

+
+ ) : ( +
+ + + + + + + {!isReadOnly && } + + + + {categories.map((cat, idx) => { + const isLast = idx === categories.length - 1 && !showAddRow; + const inUse = (usageCount.get(cat.id) ?? 0) > 0; + return ( + + + + + {!isReadOnly && ( + + )} + + ); + })} + + {showAddRow && ( + + + + + {!isReadOnly && ( + + )} + + )} + + {error && ( + + + + )} + +
NameTypeEventsActions
+ {editingId === cat.id ? ( + setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleEditSave(cat); + if (e.key === "Escape") { setEditingId(null); setEditName(""); setError(null); } + }} + style={inputStyle} + autoFocus + /> + ) : ( + cat.name + )} + + {cat.is_custom ? "Custom" : "Default"} + + {(usageCount.get(cat.id) ?? 0) > 0 ? ( + + {usageCount.get(cat.id)} + + ) : ( + - + )} + + {editingId === cat.id ? ( + <> + + + + ) : ( + <> + + + + )} +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && name.trim()) handleAdd(); + if (e.key === "Escape") { setShowAddRow(false); setName(""); setError(null); } + }} + style={inputStyle} + autoFocus + /> + Custom + - + + + +
+ {error} +
+
+ )} +
+ ); +} diff --git a/frontend/components/events/CsvImportBar.tsx b/frontend/components/events/CsvImportBar.tsx new file mode 100644 index 00000000..15f1e71c --- /dev/null +++ b/frontend/components/events/CsvImportBar.tsx @@ -0,0 +1,946 @@ +"use client"; + +import { useRef, useState } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { + IconUpload, IconExport, IconSheets, IconWarning, IconChevronDown, IconTemplate, +} from "@/components/ui/Icons"; +import { + eventsApi, timeBlocksApi, categoriesApi, + EventCreate, TimeBlock, TournamentCategory, Event, +} from "@/lib/api"; +import { parseApiError } from "@/lib/errors"; +import { useTournament } from "@/lib/useTournament"; + +// ─── 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; +} + +// ─── Events import types + parser ──────────────────────────────────────────── + +interface ParsedEventRow { + rowNum: number; + name: string; + category: string; + division: "B" | "C" | null; + eventType: "standard" | "trial"; + building: string; + room: string; + floor: string; + volunteersNeeded: number; +} + +interface EventParseResult { + valid: ParsedEventRow[]; + errors: Array<{ rowNum: number; message: string }>; + newCategories: string[]; + hasBlocksColumn: boolean; +} + +function parseEventsCSV( + text: string, + categories: TournamentCategory[], +): EventParseResult { + const rows = parseCSVText(text); + if (rows.length === 0) { + 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 hasBlocksColumn = headers.includes("blocks"); + const knownCatNames = new Set(categories.map((c) => c.name.toLowerCase())); + + const valid: ParsedEventRow[] = []; + const errors: Array<{ rowNum: number; message: string }> = []; + const newCategoriesSet = new Set(); + + 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 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); + } + + 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, + }); + } + + return { + valid, + errors, + newCategories: [...newCategoriesSet], + hasBlocksColumn, + }; +} + +// ─── 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[], +): string { + const catMap = new Map(categories.map((c) => [c.id, c.name])); + const rows = [ + ["name", "category", "division", "type", "building", "room", "floor", "volunteers_needed"], + ...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), + ]), + ]; + 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", + '"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 = [ + "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." }, +]; + +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, + events, + categories, + 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); + + 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 handleEventsFile = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = (ev) => { + const result = parseEventsCSV(ev.target?.result as string, categories); + setPreview({ kind: "events", result }); + setImportError(null); + }; + reader.readAsText(file); + e.target.value = ""; + }; + + 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 handleImportEvents = async (result: EventParseResult) => { + setImporting(true); + setImportError(null); + setImportProgress({ done: 0, total: result.valid.length }); + try { + const catMap = new Map( + categories.map((c) => [c.name.toLowerCase(), c.id]) + ); + for (const catName of result.newCategories) { + const created = await categoriesApi.create(tournamentId, catName); + catMap.set(catName.toLowerCase(), created.id); + } + + for (let i = 0; i < result.valid.length; i++) { + const row = result.valid[i]; + const body: EventCreate = { + tournament_id: tournamentId, + 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: [], + }; + await eventsApi.create(tournamentId, body); + 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 (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), `${tournamentSlug}-events-${todayStr()}.csv`); + setOpenMenu(null); + }; + + const handleExportTimeBlocks = () => { + downloadCSV(buildTimeBlocksCSV(timeBlocks, events), `${tournamentSlug}-time-blocks-${todayStr()}.csv`); + setOpenMenu(null); + }; + + // ── Import menu items ───────────────────────────────────────────────────── + + const importItems: MenuItem[] = [ + { + label: "Events", + icon: , + onClick: () => { setOpenMenu(null); eventsFileRef.current?.click(); }, + }, + { + label: "Time Blocks", + icon: , + onClick: () => { setOpenMenu(null); timeBlocksFileRef.current?.click(); }, + }, + "separator", + { + label: "Events template", + icon: , + onClick: () => { + downloadCSV(EVENTS_CSV_TEMPLATE, "events-import-template.csv"); + setOpenMenu(null); + }, + }, + { + label: "Time Blocks template", + icon: , + 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 ( + <> +
+ {/* ── Import ── */} +
+ + {openMenu === "import" && } +
+ + {/* ── Export ── */} +
+ + {openMenu === "export" && } +
+ + {/* ── Help popover ── */} +
+ + + {openMenu === "help" && ( +
e.stopPropagation()} + > +
+ + CSV Import Guide + + +
+ + +
+ +
+ +

+ Headers are case-insensitive and order-independent. +

+
+ )} +
+ +
+ + {/* ── Google Sheets — disabled ── */} + +
+ + {/* Backdrop — closes any open menu */} + {openMenu !== null && ( +
setOpenMenu(null)} + /> + )} + + {/* Hidden file inputs */} + + + + {/* Import preview modal */} + {preview && ( + { setPreview(null); setImportError(null); }} + /> + )} + + ); +} + +// ─── Help table ─────────────────────────────────────────────────────────────── + +function HelpTable({ + title, + columns, +}: { + title: string; + columns: Array<{ col: string; req: boolean; desc: string }>; +}) { + return ( + <> +

+ {title} +

+ + + + + + + + + + {columns.map((c) => ( + + + + + + ))} + +
ColumnReqDescription
+ {c.col} + + {c.req && } + + {c.desc} +
+ + ); +} + +// ─── Import preview modal ───────────────────────────────────────────────────── + +interface PreviewModalProps { + preview: NonNullable<{ kind: "events"; result: EventParseResult } | { kind: "timeblocks"; result: TimeBlockParseResult }>; + importing: boolean; + progress: { done: number; total: number } | null; + error: string | null; + onImport: () => void; + onCancel: () => void; +} + +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 hasBlocksCol = isEvents ? (preview.result as EventParseResult).hasBlocksColumn : false; + const hasWarnings = newCategories.length > 0; + + const title = isEvents ? "Import events — preview" : "Import time blocks — preview"; + + return ( + + {/* Summary */} +
+

0 ? "4px" : 0 }}> + {valid.length === 0 + ? `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. +

+ )} +
+ + {/* 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 && ( +

+ New categories will be created: {newCategories.join(", ")} +

+ )} +
+
+ )} + + {/* Row errors */} + {errors.length > 0 && } + + {/* Progress */} + {progress && ( +

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

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

+ {error} +

+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/components/events/DeleteBlockModal.tsx b/frontend/components/events/DeleteBlockModal.tsx new file mode 100644 index 00000000..f4b16a71 --- /dev/null +++ b/frontend/components/events/DeleteBlockModal.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import { Modal } from "@/components/ui/Modal"; +import { Button } from "@/components/ui/Button"; +import { IconWarning } from "@/components/ui/Icons"; +import { TimeBlock } from "@/lib/api"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface AffectedEvent { + id: number; + name: string; + division: string | null; +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + block: TimeBlock; + affectedEvents: AffectedEvent[]; + onConfirm: () => Promise; + onCancel: () => void; +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DeleteBlockModal({ block, affectedEvents, onConfirm, onCancel }: Props) { + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + const hasEvents = affectedEvents.length > 0; + + const handleConfirm = async () => { + setDeleting(true); + setError(null); + try { + await onConfirm(); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to delete block"); + setDeleting(false); + } + }; + + return ( + + {/* Warning banner — only shown when events are affected */} + {hasEvents && ( +
+ +

+ These events will become unscheduled. You can reassign their blocks before deleting. +

+
+ )} + + {/* Affected event list */} + {hasEvents && ( +
+ {affectedEvents.map((ev, i) => ( +
+ + {ev.name} + + {ev.division && ( + + Div {ev.division} + + )} +
+ ))} +
+ )} + + {/* No-events confirmation copy */} + {!hasEvents && ( +

+ This block has no events assigned. It will be permanently removed. +

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

+ {error} +

+ )} + + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/frontend/components/events/EventCard.tsx b/frontend/components/events/EventCard.tsx new file mode 100644 index 00000000..bcc582cc --- /dev/null +++ b/frontend/components/events/EventCard.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { Event, TournamentCategory } from "@/lib/api"; +import { catColorVars } from "@/lib/formatters"; +import { memo } from "react"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + event: Event; + categories: TournamentCategory[]; + onClick?: () => void; + selectMode?: boolean; + selected?: boolean; + onToggleSelect?: () => void; +} + +// ─── Small badge ───────────────────────────────────────────────────────────── + +function Chip({ + label, + bg, + color, + border, + dashed, +}: { + label: string; + bg?: string; + color?: string; + border?: string; + dashed?: boolean; +}) { + return ( + + {label} + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +function EventCardComponent({ 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; + const catColor = catIdx >= 0 ? catColorVars(catIdx) : null; + + // Location string + const locationParts = [event.building, event.room, event.floor].filter(Boolean); + const location = locationParts.join(" · "); + + const handleClick = selectMode ? onToggleSelect : onClick; + const isInteractive = !!handleClick; + + return ( +
{ + 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 ── */} +
+ + {event.name} + + + {event.division && ( + + )} +
+ + {/* ── Row 2: category + type badges ── */} + {(catName || event.event_type === "trial") && ( +
+ {catName && catColor && ( + + )} + {event.event_type === "trial" && ( + + )} +
+ )} + + {/* ── Row 3: location ── */} + {location && ( + + {location} + + )} + + {/* ── Row 4: time block tags or Unscheduled ── */} +
+ {event.time_blocks.length === 0 ? ( + + ) : ( + event.time_blocks.map((block) => ( + + {block.label} + + )) + )} +
+
+ ); +} + +export const EventCard = memo( + EventCardComponent, + (prev, next) => + prev.event === next.event && + prev.categories === next.categories && + prev.selectMode === next.selectMode && + prev.selected === next.selected, +); diff --git a/frontend/components/events/EventCardGrid.tsx b/frontend/components/events/EventCardGrid.tsx new file mode 100644 index 00000000..5cd10d36 --- /dev/null +++ b/frontend/components/events/EventCardGrid.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useState, useMemo, useEffect } from "react"; +import { Event, TournamentCategory } from "@/lib/api"; +import { catColorVars } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; +import { IconPlus, IconSearch } from "@/components/ui/Icons"; +import { EventCard } from "@/components/events/EventCard"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + events: Event[]; + categories: TournamentCategory[]; + onCardClick: (event: Event) => void; + onAddClick: () => void; + hideFilters?: boolean; + selectMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (id: number) => void; + onEnterSelectMode?: () => void; + onFilteredIdsChange?: (ids: number[]) => void; +} + +// ─── Filter types ───────────────────────────────────────────────────────────── + +type DivFilter = "B" | "C" | null; +type TypeFilter = "standard" | "trial" | null; + +// ─── Component ──────────────────────────────────────────────────────────────── + +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; + 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, hideFilters]); + + useEffect(() => { + onFilteredIdsChange?.(filtered.map((e) => e.id)); + }, [filtered, onFilteredIdsChange]); + + // ── Shared styles ────────────────────────────────────────────────────────── + + const filterBtn = (active: boolean): React.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", + }); + + return ( +
+ {/* ── Toolbar ── */} + {!hideFilters && ( +
+ {/* 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" : ""} + + + {/* Select button */} + {!selectMode && ( + + )} + + {/* Add event */} + +
+ )} + + {/* ── Category chips ── */} + {!hideFilters && categories.length > 0 && ( +
+ + {categories.map((cat, idx) => { + const active = categoryId === cat.id; + const cv = catColorVars(idx); + return ( + + ); + })} +
+ )} + + {/* ── Cards grid ── */} + {filtered.length === 0 ? ( +
+ {events.length === 0 ? ( + <> + No events yet. + + + ) : ( + No events match your filters. + )} +
+ ) : ( +
+ {filtered.map((event) => ( + onCardClick(event)} + selectMode={selectMode} + selected={selectedIds?.has(event.id)} + onToggleSelect={() => onToggleSelect?.(event.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/frontend/components/events/EventChip.tsx b/frontend/components/events/EventChip.tsx new file mode 100644 index 00000000..2f1fe707 --- /dev/null +++ b/frontend/components/events/EventChip.tsx @@ -0,0 +1,157 @@ +"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; + style?: React.CSSProperties; // overrides for outer div (e.g. left offset in timeline) +} + +// ─── 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, + style: styleOverride, +}: 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`, + ...styleOverride, + 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} + + )} +
+ ); +} diff --git a/frontend/components/events/EventFiltersPanel.tsx b/frontend/components/events/EventFiltersPanel.tsx new file mode 100644 index 00000000..1ebd285b --- /dev/null +++ b/frontend/components/events/EventFiltersPanel.tsx @@ -0,0 +1,525 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { TimeBlock, TournamentCategory } from "@/lib/api"; +import { catColorVars } from "@/lib/formatters"; +import { Button } from "@/components/ui/Button"; + +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: "28px", + padding: "0 10px", + borderRadius: "var(--radius-md)", + 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 SectionLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function SelectedChip({ label, onRemove }: { label: string; onRemove: () => void }) { + 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 [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(); + 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 || draft.buildings.includes(cleaned) || !buildingOptions.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: [], + }); + + 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-sans)", + fontSize: "13px", + padding: "8px 12px", + cursor: "pointer", + }; + + const fieldInput: React.CSSProperties = { + width: "100%", + height: "34px", + padding: "0 12px", + fontFamily: "var(--font-sans)", + fontSize: "13px", + background: "var(--color-surface)", + color: "var(--color-text-primary)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + outline: "none", + boxSizing: "border-box", + }; + + return ( + <> + + + {/* Backdrop */} +
+ + {/* Panel */} +
{ if (isClosing) onClose(); }} + style={{ + position: "fixed", + top: "var(--topbar-height)", + right: 0, + width: "360px", + height: "calc(100vh - var(--topbar-height))", + background: "var(--color-surface)", + borderLeft: "1px solid var(--color-border)", + boxShadow: "var(--shadow-lg)", + zIndex: 100, + display: "flex", + flexDirection: "column", + overflow: "hidden", + animation: isClosing + ? "filterPanelOut 200ms cubic-bezier(0.55, 0, 1, 0.45) forwards" + : "filterPanelIn 220ms cubic-bezier(0.25, 0.46, 0.45, 0.94)", + }} + > + {/* Header */} +
+

+ Filters +

+ +
+ + {/* Body */} +
+ + {/* Category */} +
+ Category +
+ {categories.map((cat, idx) => { + const cv = catColorVars(idx); + const active = draft.categoryIds.includes(cat.id); + return ( + + ); + })} + +
+
+ + {/* Division */} +
+ Division +
+ {(["B", "C"] as const).map((div) => { + const active = draft.divisions.includes(div); + const bg = `var(--color-div-${div.toLowerCase()}-subtle)`; + const color = `var(--color-div-${div.toLowerCase()}-text)`; + const border = active ? `var(--color-div-${div.toLowerCase()})` : "var(--color-border)"; + return ( + + ); + })} + +
+
+ + {/* Building */} +
+ Building + {draft.buildings.length > 0 && ( +
+ {draft.buildings.map((b) => ( + setDraft((d) => ({ ...d, buildings: d.buildings.filter((x) => x !== b) }))} + /> + ))} +
+ )} +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setBuildingOpen(false); + } + }} + > + setBuildingQuery(e.target.value)} + onFocus={() => setBuildingOpen(true)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (buildingSuggestions.length > 0) addBuilding(buildingSuggestions[0]); + } + if (e.key === "Escape") setBuildingOpen(false); + }} + placeholder="Type or select building..." + style={fieldInput} + /> + {buildingOpen && (buildingSuggestions.length > 0 || buildingQuery.trim().length > 0) && ( +
+ {buildingSuggestions.length > 0 ? buildingSuggestions.map((b) => ( + + )) : ( +
+ No results +
+ )} +
+ )} +
+
+ + {/* Time blocks */} +
+ Time blocks + {draft.timeBlockIds.length > 0 && ( +
+ {draft.timeBlockIds.map((id) => ( + setDraft((d) => ({ ...d, timeBlockIds: d.timeBlockIds.filter((x) => x !== id) }))} + /> + ))} +
+ )} +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node)) { + setBlockOpen(false); + } + }} + > + 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} + /> + {blockOpen && (blockSuggestions.length > 0 || blockQuery.trim().length > 0) && ( +
+ {blockSuggestions.length > 0 ? blockSuggestions.map((b) => ( + + )) : ( +
+ No results +
+ )} +
+ )} +
+
+ +
+ + {/* Footer */} +
+ +
+ + +
+
+
+ + ); +} diff --git a/frontend/components/events/EventSidePanel.tsx b/frontend/components/events/EventSidePanel.tsx new file mode 100644 index 00000000..b33a4d0f --- /dev/null +++ b/frontend/components/events/EventSidePanel.tsx @@ -0,0 +1,985 @@ +"use client"; + +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 ──────────────────────────────────────────────────────────────────── + +interface FormState { + name: string; + category_id: number | null; + division: "B" | "C" | null; + event_type: "standard" | "trial"; + building: string; + room: string; + floor: string; + volunteers_needed: number; + time_block_ids: number[]; +} + +// Sentinel used in multi-edit to mean "user hasn't touched this field — skip it" +const NO_CHANGE = "__nc__" as const; +type NoChange = typeof NO_CHANGE; + +interface MultiEditForm { + division: "B" | "C" | null | NoChange; + category_id: number | null | NoChange; + event_type: "standard" | "trial" | NoChange; + volunteers_needed: number | NoChange; + time_block_ids: number[]; + timeBlocksDirty: boolean; // false = "no change"; true = explicit intent (even if empty) +} + +function emptyMultiEditForm(): MultiEditForm { + return { + division: NO_CHANGE, + category_id: NO_CHANGE, + event_type: NO_CHANGE, + volunteers_needed: NO_CHANGE, + time_block_ids: [], + timeBlocksDirty: false, + }; +} + +function buildMultiEditPayload(form: MultiEditForm): Partial { + const payload: Partial = {}; + if (form.division !== NO_CHANGE) payload.division = form.division; + if (form.category_id !== NO_CHANGE) payload.category_id = form.category_id; + if (form.event_type !== NO_CHANGE) payload.event_type = form.event_type; + if (form.volunteers_needed !== NO_CHANGE) payload.volunteers_needed = form.volunteers_needed; + if (form.timeBlocksDirty) payload.time_block_ids = form.time_block_ids; + return payload; +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + mode: "add" | "edit" | "multi-edit"; + event?: Event; + eventCount?: number; // "multi-edit" only + timeBlocks: TimeBlock[]; + categories: TournamentCategory[]; + isReadOnly?: boolean; + onSave: (data: Omit) => Promise; + onMultiSave?: (data: Partial>) => Promise; // "multi-edit" only + onCreateCategory: (name: string) => Promise; + onClose: () => void; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function emptyForm(): FormState { + return { + name: "", + category_id: null, + division: null, + event_type: "standard", + building: "", + room: "", + floor: "", + volunteers_needed: 2, + time_block_ids: [], + }; +} + +function fromEvent(e: Event): FormState { + return { + name: e.name, + category_id: e.category_id, + division: e.division, + event_type: e.event_type, + building: e.building ?? "", + room: e.room ?? "", + floor: e.floor ?? "", + volunteers_needed: e.volunteers_needed, + time_block_ids: e.time_block_ids ?? [], + }; +} + +function isDirty(a: FormState, b: FormState): boolean { + return ( + a.name !== b.name || + a.category_id !== b.category_id || + a.division !== b.division || + a.event_type !== b.event_type || + a.building !== b.building || + a.room !== b.room || + a.floor !== b.floor || + a.volunteers_needed !== b.volunteers_needed || + JSON.stringify((a.time_block_ids ?? []).slice().sort()) !== + JSON.stringify((b.time_block_ids ?? []).slice().sort()) + ); +} + +// ─── Sub-components ─────────────────────────────────────────────────────────── + +function FieldLabel({ children, required }: { children: React.ReactNode; required?: boolean }) { + return ( + + ); +} + +const fieldInput: React.CSSProperties = { + width: "100%", + height: "34px", + padding: "0 10px", + fontFamily: "var(--font-mono)", + fontSize: "13px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + outline: "none", + boxSizing: "border-box", +}; + +function SegmentedControl({ + value, + options, + onChange, + disabled, +}: { + value: string | null; + options: { label: string; value: string | null }[]; + onChange: (v: string | null) => void; + disabled?: boolean; +}) { + return ( +
+ {options.map((opt, idx) => { + const active = opt.value === value; + const isLast = idx === options.length - 1; + return ( + + ); + })} +
+ ); +} + +function CategorySelect({ + categories, + value, + onChange, + onCreateCategory, + disabled, + showNoChange, +}: { + categories: TournamentCategory[]; + value: number | null | NoChange; + onChange: (id: number | null | NoChange) => void; + onCreateCategory: (name: string) => Promise; + disabled?: boolean; + showNoChange?: boolean; +}) { + const [creating, setCreating] = useState(false); + const [newName, setNewName] = useState(""); + const [saving, setSaving] = useState(false); + const [createError, setCreateError] = useState(null); + const createInputRef = useRef(null); + + useEffect(() => { + if (creating) setTimeout(() => createInputRef.current?.focus(), 30); + }, [creating]); + + const cancelCreate = () => { + setCreating(false); + setNewName(""); + setCreateError(null); + }; + + const handleCreate = async () => { + const trimmed = newName.trim(); + if (!trimmed) return; + setSaving(true); + setCreateError(null); + try { + const cat = await onCreateCategory(trimmed); + onChange(cat.id); + cancelCreate(); + } catch (e) { + setCreateError(e instanceof Error ? e.message : "Failed to create"); + } finally { + setSaving(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { e.preventDefault(); handleCreate(); } + if (e.key === "Escape") { cancelCreate(); } + }; + + const rowStyle = (active: boolean): React.CSSProperties => ({ + display: "flex", + alignItems: "center", + gap: "8px", + width: "100%", + padding: "6px 10px", + fontFamily: "var(--font-mono)", + fontSize: "13px", + color: active ? "var(--color-accent)" : "var(--color-text-primary)", + background: active ? "var(--color-accent-subtle)" : "transparent", + border: "none", + borderBottom: "1px solid var(--color-border)", + cursor: disabled ? "not-allowed" : "pointer", + textAlign: "left", + opacity: disabled ? 0.6 : 1, + }); + + const dot = (active: boolean) => ( + + ); + + const allOptions: { id: number | null | NoChange; name: string }[] = [ + ...(showNoChange ? [{ id: NO_CHANGE, name: "— no change —" }] : []), + { id: null, name: "— None —" }, + ...categories.map((c) => ({ id: c.id as number | null, name: c.name })), + ]; + + return ( +
+ {allOptions.map((opt) => { + const active = opt.id === value; + return ( + + ); + })} + + {/* Add row */} + {!creating && !disabled && ( + + )} + + {/* Inline create form */} + {creating && ( +
+ setNewName(e.target.value)} + onKeyDown={handleKeyDown} + style={{ ...fieldInput, marginBottom: "6px" }} + /> + {createError && ( +

+ {createError} +

+ )} +
+ + +
+
+ )} +
+ ); +} + +function TimeBlockChip({ + block, + selected, + onClick, + disabled, +}: { + block: TimeBlock; + selected: boolean; + onClick: () => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function EventSidePanel({ + mode, + event, + eventCount, + timeBlocks, + categories, + isReadOnly = false, + onSave, + onMultiSave, + onCreateCategory, + onClose, +}: Props) { + const isMultiEdit = mode === "multi-edit"; + const initial = mode === "edit" && event ? fromEvent(event) : emptyForm(); + const [form, setForm] = useState(initial); + const [savedBase, setSavedBase] = useState(initial); + const [meForm, setMeForm] = useState(emptyMultiEditForm()); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [noOpNotice, setNoOpNotice] = useState(false); + 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(); + setForm(base); + setSavedBase(base); + setMeForm(emptyMultiEditForm()); + setError(null); + setNoOpNotice(false); + setDiscarding(false); + if (!isMultiEdit) setTimeout(() => nameRef.current?.focus(), 50); + }, [event?.id, mode]); // eslint-disable-line react-hooks/exhaustive-deps + + const dirty = isDirty(form, savedBase); + const setMe = (field: K, value: MultiEditForm[K]) => { + setMeForm((f) => ({ ...f, [field]: value })); + setNoOpNotice(false); + }; + + const set = (field: K, value: FormState[K]) => + setForm((f) => ({ ...f, [field]: value })); + + const toggleBlock = (id: number) => + set("time_block_ids", form.time_block_ids.includes(id) + ? form.time_block_ids.filter((x) => x !== id) + : [...form.time_block_ids, id]); + + const handleClose = useCallback(() => { + if (dirty && !isReadOnly) { + setDiscarding(true); + } else { + triggerClose(); + } + }, [dirty, isReadOnly, triggerClose]); + + // Escape key closes (with guard) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [handleClose]); + + const handleSave = async (andAdd = false) => { + if (isMultiEdit) { + const payload = buildMultiEditPayload(meForm); + if (Object.keys(payload).length === 0) { + setNoOpNotice(true); + return; + } + setSaving(true); + setError(null); + try { + await onMultiSave!(payload); + setMeForm(emptyMultiEditForm()); + setNoOpNotice(false); + } catch (e) { + setError(parseApiError(e)); + } finally { + setSaving(false); + } + return; + } + + setSaving(true); + setError(null); + try { + if (!form.name.trim()) return; + await onSave({ + name: form.name.trim(), + category_id: form.category_id, + division: form.division, + event_type: form.event_type, + building: form.building || null, + room: form.room || null, + floor: form.floor || null, + volunteers_needed: form.volunteers_needed, + time_block_ids: form.time_block_ids, + }); + if (andAdd) { + const blank = emptyForm(); + setForm(blank); + setSavedBase(blank); + setTimeout(() => nameRef.current?.focus(), 50); + } else { + triggerClose(); + } + } catch (e) { + setError(parseApiError(e)); + } finally { + setSaving(false); + } + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + const title = isMultiEdit + ? `Edit ${eventCount ?? 0} event${(eventCount ?? 0) !== 1 ? "s" : ""}` + : isReadOnly + ? (event?.name ?? "Event") + : mode === "add" ? "Add event" : "Edit event"; + + return ( + <> + + + {/* Backdrop */} +
+ + {/* Panel */} +
{ if (isClosing) onClose(); }} + > + {/* ── Header ── */} +
+

+ {title} +

+ +
+ + {/* ── Discard confirmation strip ── */} + {discarding && ( +
+ + Discard unsaved changes? + +
+ + +
+
+ )} + + {/* ── Scrollable form body ── */} +
+ + {/* Multi-edit info banner */} + {isMultiEdit && ( +
+ + Editing {eventCount ?? 0} event{(eventCount ?? 0) !== 1 ? "s" : ""} + +
+ Fields you change will be applied to all selected events. +
+ Fields left unchanged will not be modified. +
+ )} + + {/* Name */} +
+ Name + set("name", e.target.value)} + style={{ ...fieldInput, opacity: isMultiEdit ? 0.5 : 1 }} + /> +
+ + {/* Category */} +
+ Category + {isMultiEdit ? ( + setMe("category_id", id as number | null | NoChange)} + onCreateCategory={onCreateCategory} + disabled={isReadOnly} + showNoChange + /> + ) : ( + set("category_id", id as number | null)} + onCreateCategory={onCreateCategory} + disabled={isReadOnly} + /> + )} +
+ + {/* Division */} +
+ Division + isMultiEdit + ? setMe("division", v as "B" | "C" | null) + : set("division", v as "B" | "C" | null) + } + disabled={isReadOnly} + /> + {isMultiEdit && meForm.division === NO_CHANGE && ( + + — no change — + + )} +
+ + {/* Type */} +
+ Type + isMultiEdit + ? setMe("event_type", v as "standard" | "trial") + : set("event_type", v as "standard" | "trial") + } + disabled={isReadOnly} + /> + {isMultiEdit && meForm.event_type === NO_CHANGE && ( + + — no change — + + )} +
+ + {/* Location row */} +
+ Location +
+
+ set("building", e.target.value)} + style={fieldInput} + /> +
+
+ set("room", e.target.value)} + style={fieldInput} + /> +
+
+ set("floor", e.target.value)} + style={fieldInput} + /> +
+
+
+ + {/* Volunteers needed */} +
+ Volunteers needed + {isMultiEdit ? ( + { + const n = parseInt(e.target.value, 10); + setMe("volunteers_needed", isNaN(n) || n < 1 ? NO_CHANGE : n); + }} + style={{ ...fieldInput, width: "90px" }} + /> + ) : ( + set("volunteers_needed", Math.max(1, Number(e.target.value)))} + style={{ ...fieldInput, width: "90px" }} + /> + )} +
+ + {/* Time blocks */} +
+
+ Time blocks + {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) => { + 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} + /> + ); + })} +
+ )} +
+
+ + {/* ── Footer ── */} +
+ {/* API error */} + {error && ( +

+ {error} +

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

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

+ )} + + {isReadOnly ? ( + + ) : isMultiEdit ? ( + + ) : mode === "add" ? ( +
+ + +
+ ) : ( + + )} +
+
+ + ); +} diff --git a/frontend/components/events/EventTable.tsx b/frontend/components/events/EventTable.tsx new file mode 100644 index 00000000..8b7584fc --- /dev/null +++ b/frontend/components/events/EventTable.tsx @@ -0,0 +1,1215 @@ +"use client"; + +import { useState, useRef, useEffect, useMemo, useCallback, memo, 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 DivFilter = "B" | "C" | null; +type TypeFilter = "standard" | "trial" | null; + +const COL_W = { + select: 40, + name: 220, + category: 200, + division: 90, + type: 90, + building: 150, + room: 200, + floor: 70, + volunteers: 90, + timeBlocks: 800, +} as const; + +interface Props { + events: Event[]; + categories: TournamentCategory[]; + timeBlocks: TimeBlock[]; + onUpdate: (id: number, delta: Partial) => Promise; + onCreateCategory: (name: string) => Promise; + onAddClick: () => void; + hideFilters?: boolean; + isReadOnly?: boolean; + selectMode?: boolean; + selectedIds?: Set; + onToggleSelect?: (id: number) => void; + onEnterSelectMode?: () => void; + onFilteredIdsChange?: (ids: number[]) => void; +} + +// ─── Shared styles ──────────────────────────────────────────────────────────── + +const cell: CSSProperties = { + padding: "0 10px", + height: "40px", + verticalAlign: "middle", + borderBottom: "1px solid var(--color-border)", + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", +}; + +const cellText: CSSProperties = { + fontFamily: "var(--font-mono)", + fontSize: "12px", + color: "var(--color-text-primary)", +}; + +const inputStyle: CSSProperties = { + width: "100%", + height: "28px", + padding: "0 6px", + fontFamily: "var(--font-mono)", + fontSize: "12px", + color: "var(--color-text-primary)", + background: "var(--color-surface)", + border: "1px solid var(--color-accent)", + borderRadius: "var(--radius-sm)", + outline: "none", + boxSizing: "border-box", +}; + +// ─── DivisionCell ───────────────────────────────────────────────────────────── + +function DivisionCell({ + value, + onCommit, + isReadOnly, +}: { + value: "B" | "C" | null; + onCommit: (v: "B" | "C" | null) => void; + isReadOnly?: boolean; +}) { + const opts: ("B" | "C" | null)[] = ["B", "C", null]; + return ( +
+ {opts.map((opt) => { + const active = opt === value; + return ( + + ); + })} +
+ ); +} + +// ─── TypeCell ───────────────────────────────────────────────────────────────── + +function TypeCell({ + value, + onToggle, + isReadOnly, +}: { + value: "standard" | "trial"; + onToggle: () => void; + isReadOnly?: boolean; +}) { + const isTrial = value === "trial"; + return ( + + ); +} + +// ─── CategoryCell ───────────────────────────────────────────────────────────── + +function CategoryCell({ + value, + categories, + onCommit, + onCreateCategory, + isReadOnly, +}: { + value: number | null; + categories: TournamentCategory[]; + onCommit: (id: number | null) => Promise; + 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); + // optimistic: undefined = use prop value, anything else = pending commit + const [optimistic, setOptimistic] = useState(undefined); + const ref = useRef(null); + const inputRef = useRef(null); + + useEffect(() => { + if (creating) setTimeout(() => inputRef.current?.focus(), 30); + }, [creating]); + + useEffect(() => { + if (!open) { setCreating(false); setNewName(""); } + }, [open]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + // 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); + setOptimistic(id); + try { + await onCommit(id); + } catch { + setOptimistic(undefined); // revert on failure + } + }; + + const handleCreate = async () => { + const trimmed = newName.trim(); + if (!trimmed) return; + setSaving(true); + try { + const created = await onCreateCategory(trimmed); + setOpen(false); + await onCommit(created.id); + } finally { + setSaving(false); + setNewName(""); + } + }; + + return ( +
+ + + {open && ( +
+ {/* No category option */} + + + {categories.length > 0 && ( +
+ )} + + {categories.map((c, idx) => { + const cv2 = catColorVars(idx); + const active = c.id === displayValue; + return ( + + ); + })} + +
+ + {creating ? ( +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + if (e.key === "Escape") { setCreating(false); setNewName(""); } + }} + placeholder="Category name" + style={{ + flex: 1, + height: "26px", + padding: "0 6px", + fontFamily: "var(--font-sans)", + fontSize: "12px", + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-sm)", + outline: "none", + background: "var(--color-surface)", + color: "var(--color-text-primary)", + boxSizing: "border-box", + }} + /> + +
+ ) : ( + + )} +
+ )} +
+ ); +} + +// ─── TimeBlocksCell ─────────────────────────────────────────────────────────── + +function TimeBlocksCell({ + event, + timeBlocks, + onRemove, + onAdd, + isReadOnly, +}: { + event: Event; + timeBlocks: TimeBlock[]; + onRemove: (blockId: number) => Promise; + onAdd: (blockId: number) => Promise; + isReadOnly?: boolean; +}) { + const [pickerOpen, setPickerOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!pickerOpen) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setPickerOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [pickerOpen]); + + const ids = event.time_block_ids ?? []; + const assigned = timeBlocks.filter((b) => ids.includes(b.id)); + const available = timeBlocks.filter((b) => !ids.includes(b.id)); + + return ( +
+ {assigned.map((b) => ( + + {b.label} + {!isReadOnly && ( + + )} + + ))} + + {!isReadOnly && available.length > 0 && ( + + )} + + {assigned.length === 0 && isReadOnly && ( + + Unscheduled + + )} + + {pickerOpen && ( +
+ {available.map((b) => ( + + ))} + {available.length === 0 && ( +
+ All blocks assigned +
+ )} +
+ )} +
+ ); +} + +// ─── EventTableRow ──────────────────────────────────────────────────────────── + +function EventTableRow({ + event, + categories, + timeBlocks, + onUpdate, + onCreateCategory, + isReadOnly, + selectMode, + selected, + onToggleSelect, +}: { + event: Event; + categories: TournamentCategory[]; + timeBlocks: TimeBlock[]; + 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); + const stickyNameLeft = selectMode ? COL_W.select : 0; + + useEffect(() => { + if (editing) setTimeout(() => inputRef.current?.select(), 10); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editing?.col]); + + const activateText = (col: TextCol | NumCol, current: string | number | null) => { + if (isReadOnly) return; + setEditing({ col, draft: String(current ?? "") }); + }; + + const commitText = async () => { + if (!editing) return; + const { col, draft } = editing; + setEditing(null); + const trimmed = draft.trim(); + const current = String(event[col] ?? ""); + if (trimmed === current) return; + if (col === "volunteers_needed") { + const num = parseInt(trimmed, 10); + if (!isNaN(num) && num >= 1) await onUpdate(event.id, { volunteers_needed: num }); + } else { + await onUpdate(event.id, { [col]: trimmed || null }); + } + }; + + const revert = () => setEditing(null); + + 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) : ""; + + return ( + !isActive && activateText(col, rawVal)} + style={{ + ...cell, + width, + cursor: isReadOnly || isActive ? "default" : "text", + paddingLeft: 10, + paddingRight: 10, + overflow: isActive ? "visible" : "hidden", + ...(stickyLeft !== undefined + ? { + position: "sticky", + left: stickyLeft, + zIndex: 5, + background: "inherit", + boxShadow: "inset -1px 0 0 var(--color-border)", + } + : {}), + }} + > + {isActive ? ( + setEditing((s) => s ? { ...s, draft: e.target.value } : null)} + onBlur={commitText} + onKeyDown={(e) => { + if (e.key === "Enter") e.currentTarget.blur(); + if (e.key === "Escape") revert(); + }} + style={inputStyle} + /> + ) : ( + + {displayVal || "—"} + + )} + + ); + }; + + return ( + { 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", COL_W.name, stickyNameLeft)} + + + onUpdate(event.id, { category_id: id })} + onCreateCategory={onCreateCategory} + isReadOnly={isReadOnly} + /> + + + + onUpdate(event.id, { division: div })} + isReadOnly={isReadOnly} + /> + + + + onUpdate(event.id, { event_type: event.event_type === "standard" ? "trial" : "standard" })} + isReadOnly={isReadOnly} + /> + + + {renderTextCell("building", COL_W.building)} + {renderTextCell("room", COL_W.room)} + {renderTextCell("floor", COL_W.floor)} + {renderTextCell("volunteers_needed", COL_W.volunteers)} + + + + onUpdate(event.id, { + time_block_ids: (event.time_block_ids ?? []).filter((id) => id !== blockId), + }) + } + onAdd={(blockId) => + onUpdate(event.id, { + time_block_ids: [...(event.time_block_ids ?? []), blockId], + }) + } + isReadOnly={isReadOnly} + /> + + + ); +} + +const MemoEventTableRow = memo( + EventTableRow, + (prev, next) => + prev.event === next.event && + prev.categories === next.categories && + prev.timeBlocks === next.timeBlocks && + prev.isReadOnly === next.isReadOnly && + prev.selectMode === next.selectMode && + prev.selected === next.selected, +); + +// ─── EventTable ─────────────────────────────────────────────────────────────── + +export function EventTable({ + events, + categories, + timeBlocks, + onUpdate, + onCreateCategory, + onAddClick, + hideFilters = false, + isReadOnly, + 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 tableScrollRef = useRef(null); + const headerLabelRefs = useRef>(new Map()); + const latestTableScrollRef = useRef(0); + const headerRafRef = useRef(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; + 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, hideFilters]); + + useEffect(() => { + onFilteredIdsChange?.(filtered.map((e) => e.id)); + }, [filtered, onFilteredIdsChange]); + + 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" }[] = useMemo(() => ([ + ...(selectMode ? [{ label: "", width: COL_W.select as number | "auto" }] : []), + { label: "Name", width: COL_W.name }, + { label: "Category", width: COL_W.category }, + { label: "Division", width: COL_W.division }, + { label: "Type", width: COL_W.type }, + { label: "Building", width: COL_W.building }, + { label: "Room", width: COL_W.room }, + { label: "Floor", width: COL_W.floor }, + { label: "Volunteers", width: COL_W.volunteers }, + { label: "Time Blocks", width: COL_W.timeBlocks }, + ]), [selectMode]); + 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 + + COL_W.category + + COL_W.division + + COL_W.type + + COL_W.building + + COL_W.room + + COL_W.floor + + 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(${Math.round(shift)}px)`; + } + }, [headerScrollMeta, stickyPrefixWidth]); + + const onTableScroll = () => { + const node = tableScrollRef.current; + if (!node) return; + const scrollLeft = node.scrollLeft; + 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", + textAlign: "left", + fontFamily: "var(--font-sans)", + fontSize: "11px", + fontWeight: 600, + color: "var(--color-text-secondary)", + textTransform: "uppercase", + letterSpacing: "0.04em", + background: "var(--color-bg)", + borderBottom: "1px solid var(--color-border)", + whiteSpace: "nowrap", + userSelect: "none", + position: "sticky", + top: 0, + zIndex: 10, + }; + + return ( +
+ {/* ── Toolbar ── */} + {!hideFilters && ( +
+ {/* 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" : ""} + + + {/* Select button */} + {!isReadOnly && !selectMode && ( + + )} + + {/* Add event */} + {!isReadOnly && ( + + )} +
+ )} + + {/* ── Category chips ── */} + {!hideFilters && categories.length > 0 && ( +
+ + {categories.map((cat, idx) => { + const active = categoryId === cat.id; + const cv = catColorVars(idx); + return ( + + ); + })} +
+ )} + + {/* ── Table ── */} + {filtered.length === 0 ? ( +
+ {events.length === 0 ? ( + <> + No events yet. + {!isReadOnly && ( + + )} + + ) : ( + No events match your filters. + )} +
+ ) : ( +
+ + + + {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 ( + + ); + })} + + + + {filtered.map((ev) => ( + onToggleSelect?.(ev.id)} + /> + ))} + +
+ {isCheckboxCol ? ( +
{ + const ids = filtered.map((e) => e.id); + if (allSelected) { + ids.forEach((id) => { + if (selectedIds?.has(id)) onToggleSelect?.(id); + }); + } else { + ids + .filter((id) => !selectedIds?.has(id)) + .forEach((id) => onToggleSelect?.(id)); + } + }} + style={{ + display: "inline-flex", alignItems: "center", justifyContent: "center", + width: 18, height: 18, borderRadius: "3px", + border: `1.5px solid ${allSelected || someSelected ? "var(--color-accent)" : "var(--color-border)"}`, + background: allSelected ? "var(--color-accent)" : "transparent", + cursor: "pointer", + }} + > + {allSelected && ( + + + + )} + {someSelected && !allSelected && ( + + + + )} +
+ ) : ( + { + if (el) headerLabelRefs.current.set(i, el); + else headerLabelRefs.current.delete(i); + }} + style={{ + display: "inline-block", + transform: "translateX(0px)", + willChange: "transform", + }} + > + {col.label} + + )} +
+
+ )} +
+ ); +} diff --git a/frontend/components/events/EventTimeline.tsx b/frontend/components/events/EventTimeline.tsx new file mode 100644 index 00000000..3b5e85f0 --- /dev/null +++ b/frontend/components/events/EventTimeline.tsx @@ -0,0 +1,621 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { Event, TimeBlock, TournamentCategory } from "@/lib/api"; +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"; + +// ─── 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 CONTROLS_H = 50; // px — controls bar height (incl. padding) +// 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"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + events: Event[]; + timeBlocks: TimeBlock[]; + categories: TournamentCategory[]; + onEventClick: (event: Event) => void; + onAddClick: () => void; +} + +// ─── Column layout ──────────────────────────────────────────────────────────── + +export type ColumnLayout = { + block: TimeBlock; + xOffset: number; // left px from start of column grid (excl. label col) + widthFraction: number; // 1.0 for all blocks (each occupies one full colW slot) + mergedGroupId: string | null; // non-null when block is part of an overlap group +}; + +function toMinutes(t: string): number { + const [h, m] = t.split(":").map(Number); + return h * 60 + m; +} + +function blocksOverlap(a: TimeBlock, b: TimeBlock): boolean { + const s1 = toMinutes(a.start), e1 = toMinutes(a.end); + const s2 = toMinutes(b.start), e2 = toMinutes(b.end); + function toRanges(s: number, e: number): [number, number][] { + return e <= s ? [[s, 1440], [0, e]] : [[s, e]]; + } + for (const [a1, b1] of toRanges(s1, e1)) + for (const [a2, b2] of toRanges(s2, e2)) + if (a1 < b2 && b1 > a2) return true; + return false; +} + +export function resolveColumnLayout(blocks: TimeBlock[], colW: number): ColumnLayout[] { + if (blocks.length === 0) return []; + + // Union-find over block IDs to detect transitive overlap groups + const parent = new Map(blocks.map((b) => [b.id, b.id])); + + function find(id: number): number { + let root = id; + while (parent.get(root) !== root) root = parent.get(root)!; + let curr = id; + while (curr !== root) { const next = parent.get(curr)!; parent.set(curr, root); curr = next; } + return root; + } + function union(a: number, b: number) { + const ra = find(a), rb = find(b); + if (ra !== rb) parent.set(ra, rb); + } + + for (let i = 0; i < blocks.length; i++) + for (let j = i + 1; j < blocks.length; j++) + if (blocks[i].date === blocks[j].date && blocksOverlap(blocks[i], blocks[j])) + union(blocks[i].id, blocks[j].id); + + const groupSizes = new Map(); + for (const b of blocks) { + const root = find(b.id); + groupSizes.set(root, (groupSizes.get(root) ?? 0) + 1); + } + + return blocks.map((block, i) => { + const root = find(block.id); + const size = groupSizes.get(root) ?? 1; + return { + block, + xOffset: i * colW, + widthFraction: 1.0, + mergedGroupId: size > 1 ? String(root) : null, + }; + }); +} + +// ─── 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") { + 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(); + 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; +} + +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) { + const [zoomIdx, setZoomIdx] = useState(1); + const [groupBy, setGroupBy] = useState("category"); + const [colorBy, setColorBy] = useState("category"); + + // Scroll sync: timeline header rows + body sections track scrollLeft of the active scroller. + // Controls bar is intentionally excluded so it stays fixed in the x direction. + const headerRowsScrollRef = useRef(null); + const contentScrollRef = useRef(null); + const unscheduledSubRef = useRef(null); + const unscheduledContentRef = useRef(null); + const dateLabelRefs = useRef>(new Map()); + const latestScrollLeftRef = useRef(0); + const rafRef = useRef(null); + + const syncScroll = (scrollLeft: number) => { + latestScrollLeftRef.current = scrollLeft; + for (const ref of [headerRowsScrollRef, contentScrollRef, unscheduledSubRef, unscheduledContentRef]) { + if (ref.current && ref.current.scrollLeft !== scrollLeft) + ref.current.scrollLeft = scrollLeft; + } + if (rafRef.current === null) { + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + for (const dg of dateGroupsWithOffsets) { + const label = dateLabelRefs.current.get(dg.date); + if (!label) continue; + const labelWidth = label.offsetWidth; + const maxShift = Math.max(0, dg.width - 16 - labelWidth); + const shift = Math.min(Math.max(0, latestScrollLeftRef.current - dg.start), maxShift); + label.style.transform = `translateX(${shift}px)`; + } + }); + } + }; + 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 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 dateGroupsWithOffsets = withDateOffsets(dateGroups, colW); + const columnLayout = useMemo(() => resolveColumnLayout(timeBlocks, colW), [timeBlocks, colW]); + + useEffect(() => { + const scrollLeft = contentScrollRef.current?.scrollLeft ?? latestScrollLeftRef.current; + latestScrollLeftRef.current = scrollLeft; + for (const dg of dateGroupsWithOffsets) { + const label = dateLabelRefs.current.get(dg.date); + if (!label) continue; + const labelWidth = label.offsetWidth; + const maxShift = Math.max(0, dg.width - 16 - labelWidth); + const shift = Math.min(Math.max(0, scrollLeft - dg.start), maxShift); + label.style.transform = `translateX(${shift}px)`; + } + }, [dateGroupsWithOffsets]); + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + // Pre-build a map from mergedGroupId → all blocks in that group (for tooltips) + const mergedGroupBlocks = useMemo(() => { + const map = new Map(); + for (const col of columnLayout) { + if (col.mergedGroupId) { + if (!map.has(col.mergedGroupId)) map.set(col.mergedGroupId, []); + map.get(col.mergedGroupId)!.push(col.block); + } + } + return map; + }, [columnLayout]); + + // ── Shared styles ────────────────────────────────────────────────────────── + + const labelCell: React.CSSProperties = { + 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 = { + 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 zoomBtnStyle = (disabled: boolean): React.CSSProperties => ({ + width: "26px", + height: "26px", + display: "flex", + alignItems: "center", + justifyContent: "center", + 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 (lives inside the sticky header) ───────────────────────── + + const controlsBar = ( +
+
+ + + {zoomPct}% + + +
+ +
+ + + + +
+ ); + + // ── Header rows (date + block, rendered inside scroll-synced div) ───────── + + const headerRows = ( +
+ {/* Date row */} +
+
+ {dateGroupsWithOffsets.map((dg) => { + return ( +
+ { + if (el) dateLabelRefs.current.set(dg.date, el); + else dateLabelRefs.current.delete(dg.date); + }} + style={{ display: "inline-block", transform: "translateX(0px)", willChange: "transform", fontFamily: "var(--font-sans)", fontSize: "11px", fontWeight: 600, color: "var(--color-text-secondary)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", maxWidth: "calc(100% - 8px)" }} + > + {dg.label} + +
+ ); + })} +
+ + {/* Block row */} +
+
+ {columnLayout.map((col) => { + const groupBlocks = col.mergedGroupId ? (mergedGroupBlocks.get(col.mergedGroupId) ?? []) : []; + const tooltip = col.mergedGroupId + ? groupBlocks.map((b) => `${b.label} (${fmtTime(b.start)}–${fmtTime(b.end)})`).join(" and ") + " overlap" + : undefined; + return ( +
+ + {col.block.label} + + {colW >= 110 && ( + + {fmtTime(col.block.start)}–{fmtTime(col.block.end)} + + )} +
+ ); + })} +
+
+ ); + + // ── Shared outer container style ─────────────────────────────────────────── + + const outerStyle: React.CSSProperties = { + border: "1px solid var(--color-border)", + borderRadius: "var(--radius-md)", + overflow: "clip", + }; + + // ── Empty state ──────────────────────────────────────────────────────────── + + if (timeBlocks.length === 0) { + return ( +
+
+ {controlsBar} +
+
+ Add time blocks to see the timeline. +
+
+ ); + } + + // ── Render ───────────────────────────────────────────────────────────────── + + return ( +
+ + {/* ── Sticky top controls: fixed x, sticky y ── */} +
+ {controlsBar} +
+ + {/* ── Sticky timeline header rows: scroll-synced on x, sticky on y ── */} +
+ {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 ( +
0 ? "1px solid var(--color-border)" : undefined, + }} + > + {/* Group label — spans all event rows in this group */} +
+ + {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 }} + /> + ))} +
+ ); + })} +
+
+ ); + })} + + {/* 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 rows — own scroll-synced container */} +
+
+ {unscheduled.map((event) => ( +
+
onEventClick(event)} title={event.name}> + + {event.name}{event.division ? ` ${event.division}` : ""} + +
+
+
+ ))} +
+
+ + )} + +
+ ); +} diff --git a/frontend/components/events/TimeBlocksTable.tsx b/frontend/components/events/TimeBlocksTable.tsx new file mode 100644 index 00000000..dd58f619 --- /dev/null +++ b/frontend/components/events/TimeBlocksTable.tsx @@ -0,0 +1,507 @@ +"use client"; + +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"; + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface Props { + timeBlocks: TimeBlock[]; + events: Event[]; + isReadOnly?: boolean; + onAdd: (data: TimeBlockCreate) => Promise; + onEdit: (id: number, data: Partial) => Promise; + 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", +}; + +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, colSpan }: { date: string; colSpan: number }) { + return ( + + + {fmtDate(date)} + + + ); +} + +// ─── 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, flex: 1, minWidth: 0 }} + /> + + set("end", e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 0 }} + /> +
+ + + {/* Events count — empty for new/edit row */} + + + + + {/* Actions */} + {colSpan === 5 && ( + + + + + )} + + + {/* Validation / API error */} + {error && ( + + + {error} + + + )} + + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function TimeBlocksTable({ + timeBlocks, + events, + isReadOnly = false, + onAdd, + 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(parseApiError(e)); + } finally { + setSaving(false); + } + }; + + const handleSaveEdit = async (id: number, data: TimeBlockCreate) => { + setSaving(true); + setSaveError(null); + try { + await onEdit(id, data); + setEditingId(null); + } catch (e) { + setSaveError(parseApiError(e)); + } 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 (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) { + rows.push({ type: "separator", date: block.date }); + lastDate = block.date; + } + 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 ── */} +
+ + {timeBlocks.length} block{timeBlocks.length !== 1 ? "s" : ""} + + + {!isReadOnly && ( + + )} +
+ + {/* ── 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 ── */} + {showTable && ( +
+ + + + + + + + {!isReadOnly && ( + + )} + + + + {rows.map((row, i) => { + if (row.type === "separator") { + return ( + + ); + } + + const { block } = row; + 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)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLTableRowElement).style.background = "transparent"; + }} + > + {/* Label */} + + + {/* Day */} + + + {/* Time range */} + + + {/* Events count */} + + + {/* Actions */} + {!isReadOnly && ( + + )} + + ); + })} + + {/* ── Add row at bottom ── */} + {showAddRow && ( + + )} + +
LabelDayTime rangeEventsActions
+ {block.label} + + {fmtDate(block.date)} + + {fmtTime(block.start)} + + {fmtTime(block.end)} + + {count > 0 ? ( + + {count} + + ) : ( + + )} + + + +
+
+ )} +
+ ); +} diff --git a/frontend/components/ui/Icons.tsx b/frontend/components/ui/Icons.tsx index 38b15554..f9dfa194 100644 --- a/frontend/components/ui/Icons.tsx +++ b/frontend/components/ui/Icons.tsx @@ -58,10 +58,18 @@ export function IconVolunteers({ size = 18, ...props }: IconProps) { export function IconSheets({ size = 18, ...props }: IconProps) { return ( - - - - + + + + + ); +} + +export function IconUpload({ size = 14, ...props }: IconProps) { + return ( + + + ); } @@ -257,6 +265,15 @@ export function IconWarning({ size = 16, ...props }: IconProps) { // ─── Content ───────────────────────────────────────────────────────────────── +export function IconSearch({ size = 14, ...props }: IconProps) { + return ( + + + + + ); +} + export function IconCalendar({ size = 13, ...props }: IconProps) { return ( @@ -273,4 +290,39 @@ export function IconLocation({ size = 13, ...props }: IconProps) { ); +} + +export function IconLayoutCards({ size = 14, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconLayoutTable({ size = 14, ...props }: IconProps) { + return ( + + + + + + ); +} + +/** 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 diff --git a/frontend/components/ui/Modal.tsx b/frontend/components/ui/Modal.tsx index 16003f20..091804b4 100644 --- a/frontend/components/ui/Modal.tsx +++ b/frontend/components/ui/Modal.tsx @@ -37,6 +37,8 @@ export function Modal({ title, onClose, children, width = 440 }: ModalProps) { padding: '28px', width, maxWidth: 'calc(100vw - 32px)', + maxHeight: 'calc(100vh - 32px)', + overflowY: 'auto', boxShadow: 'var(--shadow-lg)', }} onClick={(e) => e.stopPropagation()} @@ -53,4 +55,4 @@ export function Modal({ title, onClose, children, width = 440 }: ModalProps) {
) -} \ No newline at end of file +} 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 { diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 2613ae1c..680cccca 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 @@ -84,16 +88,70 @@ 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, force = false) => + api.delete(`/tournaments/${tournamentId}/blocks/${id}/${force ? "?force=true" : ""}`), +} + +// ------------------------------------------------------------------------- +// 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 }), + update: (tournamentId: number, id: number, name: string) => + api.patch(`/tournaments/${tournamentId}/categories/${id}/`, { name }), + delete: (tournamentId: number, id: number) => + api.delete(`/tournaments/${tournamentId}/categories/${id}/`), +} + +// ------------------------------------------------------------------------- +// Tournaments +// ------------------------------------------------------------------------- export interface CustomField { key: string label: string @@ -117,7 +175,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,27 +198,49 @@ 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 { + tournament_id: number + 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), + batchUpdate: (tournamentId: number, eventIds: number[], updates: Partial) => + api.patch(`/tournaments/${tournamentId}/events/batch/`, { event_ids: eventIds, updates }), delete: (tournamentId: number, id: number) => api.delete(`/tournaments/${tournamentId}/events/${id}/`), } @@ -210,16 +290,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 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."; +} diff --git a/frontend/lib/formatters.ts b/frontend/lib/formatters.ts new file mode 100644 index 00000000..25bc8d54 --- /dev/null +++ b/frontend/lib/formatters.ts @@ -0,0 +1,50 @@ +// ─── Time / date formatters ─────────────────────────────────────────────────── + +/** "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 period = h >= 12 ? "PM" : "AM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}:${mStr} ${period}`; +} + +/** "YYYY-MM-DD" → "Wed, Mar 15" */ +export function fmtDate(yyyymmdd: string): string { + const [yr, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(yr, mo - 1, d).toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); +} + +/** "YYYY-MM-DD" → "Mar 15" */ +export function fmtDateShort(yyyymmdd: string): string { + const [yr, mo, d] = yyyymmdd.split("-").map(Number); + return new Date(yr, 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)`, + }; +} 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[]; diff --git a/frontend/public/apple-touch-icon.png b/frontend/public/apple-touch-icon.png deleted file mode 100644 index 52ba921d..00000000 Binary files a/frontend/public/apple-touch-icon.png and /dev/null differ diff --git a/frontend/public/favicon-192.png b/frontend/public/favicon-192.png deleted file mode 100644 index c30c7ef5..00000000 Binary files a/frontend/public/favicon-192.png and /dev/null differ diff --git a/frontend/public/favicon-512.png b/frontend/public/favicon-512.png deleted file mode 100644 index 8bffa18f..00000000 Binary files a/frontend/public/favicon-512.png and /dev/null differ diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico deleted file mode 100644 index 881fe936..00000000 Binary files a/frontend/public/favicon.ico and /dev/null differ 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