From e5903bc11ba0993306f2411aea4169da8ca1109d Mon Sep 17 00:00:00 2001 From: VortexUK Date: Sat, 11 Jul 2026 18:04:58 +0100 Subject: [PATCH] =?UTF-8?q?feat(raids):=20raid=20planning=20=E2=80=94=20gr?= =?UTF-8?q?oup=20planner=20per=20raid=20team=20+=20personal=20availability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Officer-curated raid rosters with a drag-and-drop group planner embedded in each raid team's section of the guild Raids tab, plus a personal 3-month availability calendar on the home page. Backend: - Three new users.db tables (CREATE IF NOT EXISTS, no migration): raid_roster_roles (guild-scoped raider/raid_alt designations), raid_placements (per-team 4x6 layout keyed by team_index — schedule saves regenerate team ids, and the schedule PUT prunes layouts for removed teams), user_availability (per-user per-day; only non-default days stored so absent = available). - RaidPlanningStore + AvailabilityStore (AsyncStoreBase domains, in ALL_STORES, facade-exempt like favorites/raid_schedule). Clearing a character's role cascades their placements out of every team. - raid_planning router: GET planner payload (guild-members only — an approved claim on a character in the guild; officers flagged), officer PUTs for roles (canonical casing resolved from the roster) and placements (full-replace with structural validation: groups 1-4, slots 0-5, no double booking, group xor sitout, rostered chars only), GET/PUT /me/availability (92-day window, status whitelist). - Availability overlay: char → player via approved claims; player → day status; player display names for the duplicate-player warning. Frontend: - Raid team sections are collapsible (single team starts expanded). - RaidPlanner per team: class-coloured chips, drag & drop between slots/sitout/bench with occupied-slot swap and whole-group swap via header drag, click-to-place fallback, date picker defaulting to the team's next raid night, AFK grey-out, per-group priest/bard/enchanter warnings (data-driven from /api/classes archetype+subclass), AFK and duplicate-player callouts. Officers autosave (debounced) with status chips + a Manage Raiders panel; members get a local sandbox with a reset chip; non-members see nothing (403 → render null). - Home page: "My Raid Availability" renders only when a claimed character is rostered anywhere, starts collapsed; 3-month click-to- cycle calendar (Available → Tentative → AFK), debounced save. Tests: 23 backend (permission matrix incl. non-member 403, placement validation, availability window/status, role-clear cascade, prune, overlay via real claims) + 16 frontend logic tests (move/swap semantics, warnings, next-raid-date, dirty check). 1550 pytest green, tsc/vitest/build clean. User-reviewed locally. Co-Authored-By: Claude Fable 5 --- backend/server/api/raid_planning.py | 345 +++++++++++++ backend/server/api/raid_schedule.py | 5 + backend/server/app.py | 2 + backend/server/db/__init__.py | 4 + backend/server/db/availability.py | 62 +++ backend/server/db/availability.sql | 18 + backend/server/db/raid_planning.py | 143 ++++++ backend/server/db/raid_planning.sql | 58 +++ backend/server/db/schema.sql | 53 ++ frontend/src/components/RaidAvailability.tsx | 187 +++++++ frontend/src/pages/HomePage.tsx | 2 + .../pages/guild/GuildRaidScheduleTab.test.tsx | 10 +- .../src/pages/guild/GuildRaidScheduleTab.tsx | 75 ++- .../pages/guild/raidplanner/RaidPlanner.tsx | 457 ++++++++++++++++++ .../src/pages/guild/raidplanner/logic.test.ts | 155 ++++++ frontend/src/pages/guild/raidplanner/logic.ts | 187 +++++++ frontend/src/pages/guild/raidplanner/types.ts | 40 ++ frontend/src/useClasses.ts | 7 +- tests/server/test_db_facade.py | 11 + tests/server/test_raid_planning.py | 431 +++++++++++++++++ 20 files changed, 2227 insertions(+), 25 deletions(-) create mode 100644 backend/server/api/raid_planning.py create mode 100644 backend/server/db/availability.py create mode 100644 backend/server/db/availability.sql create mode 100644 backend/server/db/raid_planning.py create mode 100644 backend/server/db/raid_planning.sql create mode 100644 frontend/src/components/RaidAvailability.tsx create mode 100644 frontend/src/pages/guild/raidplanner/RaidPlanner.tsx create mode 100644 frontend/src/pages/guild/raidplanner/logic.test.ts create mode 100644 frontend/src/pages/guild/raidplanner/logic.ts create mode 100644 frontend/src/pages/guild/raidplanner/types.ts create mode 100644 tests/server/test_raid_planning.py diff --git a/backend/server/api/raid_planning.py b/backend/server/api/raid_planning.py new file mode 100644 index 00000000..01e35688 --- /dev/null +++ b/backend/server/api/raid_planning.py @@ -0,0 +1,345 @@ +"""Raid-planning API: officer-curated raid rosters + per-team group layouts +plus the per-user availability calendar. + +Visibility model: + * GET planner data — guild members only (the viewer holds an approved + claim on a character in this guild's roster). Officers get the same + payload with ``is_officer: true`` so the UI enables saving. + * Writes (roles + placements) — officers only (same rank gate as the + item watch / raid schedule). + * ``/me/availability`` — any authenticated user reads/writes their own + calendar; the panel is only *shown* when ``is_raider`` is true. + +The planner is date-aware but the layout is date-less: one persistent +grid per team, with availability for the requested date overlaid on it +(AFK players grey out rather than being removed). +""" + +from __future__ import annotations + +import datetime as dt +import logging + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field + +from backend.core.log_safety import scrub +from backend.server.api.guild import _fetch_and_cache_guild, _officer_chars, _roster_rank_map +from backend.server.cache import guild_cache +from backend.server.core.audit_log import audit_log +from backend.server.core.cache_keys import guild_roster_key +from backend.server.db import get_active_claims, get_display_names_for_discord_ids +from backend.server.db.availability import store as availability_db +from backend.server.db.raid_planning import VALID_ROLES +from backend.server.db.raid_planning import store as planning_db +from backend.server.db.raid_schedule import store as raid_schedule_db +from backend.server.limiter import limiter +from backend.server.server_context import current_world + +_log = logging.getLogger(__name__) + +router = APIRouter(tags=["raid-planning"]) + +GROUPS = 4 +SLOTS_PER_GROUP = 6 +#: How far ahead the availability calendar reaches (~3 months). +AVAILABILITY_HORIZON_DAYS = 92 + + +# ── Models ─────────────────────────────────────────────────────────────────── + + +class RosterEntry(BaseModel): + name: str + cls: str | None = None + level: int | None = None + role: str | None = None # raider | raid_alt | None + + +class PlacementModel(BaseModel): + character_name: str + group_num: int | None = None # 1..4 + slot: int | None = None # 0..5 + sitout: bool = False + + +class PlannerResponse(BaseModel): + is_officer: bool + team_index: int + team_count: int + date: str + roster: list[RosterEntry] # full guild roster with role overlay + placements: list[PlacementModel] + availability: dict[str, str] # char_name_lower -> tentative|afk (for roled chars) + players: dict[str, str] # char_name_lower -> player display name (roled chars w/ claims) + + +class RoleInput(BaseModel): + character_name: str = Field(min_length=1, max_length=64) + role: str | None = None # raider | raid_alt | None (clear) + + +class PlacementsInput(BaseModel): + placements: list[PlacementModel] = Field(max_length=200) + + +class AvailabilityResponse(BaseModel): + is_raider: bool + horizon_days: int + days: dict[str, str] # YYYY-MM-DD -> tentative|afk (available days absent) + + +class AvailabilityInput(BaseModel): + days: dict[str, str] = Field(max_length=200) # YYYY-MM-DD -> available|tentative|afk + + +# ── Gates ──────────────────────────────────────────────────────────────────── + + +def _require_session(request: Request) -> dict: + user = request.session.get("user") + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +async def _member_chars(discord_id: str, guild_name: str) -> set[str]: + """The viewer's approved character names (lower) that are in this guild.""" + claims_data = await get_active_claims(discord_id, world=current_world()) + approved = {c["character_name"].lower() for c in claims_data["approved"]} + if not approved: + return set() + rank_map = await _roster_rank_map(guild_name) + return {n for n in approved if n in rank_map} + + +async def _require_member(request: Request, guild_name: str) -> tuple[dict, bool]: + """401 without a session; 403 unless the viewer has an approved claim on + a character in this guild. Returns (session_user, is_officer).""" + user = _require_session(request) + members = await _member_chars(user["id"], guild_name) + if not members: + raise HTTPException(status_code=403, detail="Raid planning is visible to guild members only") + officers = await _officer_chars(user["id"], guild_name) + return user, bool(officers) + + +async def _require_officer(request: Request, guild_name: str) -> dict: + user = _require_session(request) + if not await _officer_chars(user["id"], guild_name): + raise HTTPException(status_code=403, detail="Officer access required") + return user + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def _parse_date(value: str | None) -> str: + """Validate ?date= (default today). Returns the ISO string.""" + if not value: + return dt.date.today().isoformat() + try: + return dt.date.fromisoformat(value).isoformat() + except ValueError as exc: + raise HTTPException(status_code=400, detail="date must be YYYY-MM-DD") from exc + + +async def _guild_roster(guild_name: str) -> list: + """The guild's member list (cached roster, fetch-on-miss).""" + roster, _ = guild_cache.get_stale(guild_roster_key(guild_name, current_world())) + if roster is not None: + return roster.members + full = await _fetch_and_cache_guild(guild_name) + if not full: + raise HTTPException(status_code=404, detail=f"Guild '{guild_name}' not found") + guild_data, _, _ = full + return guild_data.members + + +# ── Planner ────────────────────────────────────────────────────────────────── + + +@router.get("/guild/{guild_name}/raid-planning/{team_index}", response_model=PlannerResponse) +@limiter.limit("30/minute") +async def get_planner( + request: Request, + guild_name: str, + team_index: int, + date: str | None = None, +) -> PlannerResponse: + """Everything the planner UI needs for one team, in one call.""" + user, is_officer = await _require_member(request, guild_name) + world = current_world() + day = _parse_date(date) + + teams = await raid_schedule_db.get_schedule(world, guild_name) + team_count = len(teams) + if team_index < 0 or (team_count and team_index >= team_count): + raise HTTPException(status_code=404, detail="No such raid team") + + members = await _guild_roster(guild_name) + roles = {r["character_name"].lower(): r["role"] for r in await planning_db.get_roles(world, guild_name)} + roster = [RosterEntry(name=m.name, cls=m.cls, level=m.level, role=roles.get(m.name.lower())) for m in members] + + placements = [PlacementModel(**p) for p in await planning_db.get_placements(world, guild_name, team_index)] + + # Availability + player overlay for roled characters only (small set). + roled_lower = set(roles.keys()) + claims = await planning_db.claims_map(world) + char_to_user = {n: claims[n] for n in roled_lower if n in claims} + statuses = await availability_db.statuses_for_day(day) + availability = {n: statuses[uid] for n, uid in char_to_user.items() if uid in statuses} + display = await get_display_names_for_discord_ids(sorted(set(char_to_user.values()))) + players = {n: display.get(uid, uid) for n, uid in char_to_user.items()} + + return PlannerResponse( + is_officer=is_officer, + team_index=team_index, + team_count=team_count, + date=day, + roster=roster, + placements=placements, + availability=availability, + players=players, + ) + + +@router.put("/guild/{guild_name}/raid-planning/roles") +@limiter.limit("60/minute") +async def put_role(request: Request, guild_name: str, body: RoleInput) -> dict: + """Officer: designate a guild character as raider / raid alt (or clear).""" + user = await _require_officer(request, guild_name) + world = current_world() + + if body.role is not None and body.role not in VALID_ROLES: + raise HTTPException(status_code=400, detail="role must be raider, raid_alt or null") + + # Only real guild members can be rostered — resolve canonical casing + # from the roster so stored names match Census. + rank_map = await _roster_rank_map(guild_name) + if body.character_name.lower() not in rank_map: + raise HTTPException(status_code=404, detail=f"'{body.character_name}' is not a member of {guild_name}") + canonical = next(m.name for m in await _guild_roster(guild_name) if m.name.lower() == body.character_name.lower()) + + await planning_db.set_role(world, guild_name, canonical, body.role, updated_by=user["id"]) + audit_log( + "raid_role_set", + actor=user["id"], + guild=guild_name, + character=canonical, + role=body.role or "cleared", + ) + return {"ok": True, "character_name": canonical, "role": body.role} + + +@router.put("/guild/{guild_name}/raid-planning/{team_index}/placements") +@limiter.limit("60/minute") +async def put_placements( + request: Request, + guild_name: str, + team_index: int, + body: PlacementsInput, +) -> dict: + """Officer: replace one team's whole layout (the drag-drop save).""" + user = await _require_officer(request, guild_name) + world = current_world() + + teams = await raid_schedule_db.get_schedule(world, guild_name) + if team_index < 0 or (teams and team_index >= len(teams)): + raise HTTPException(status_code=404, detail="No such raid team") + + roles = {r["character_name"].lower(): r["role"] for r in await planning_db.get_roles(world, guild_name)} + + seen_chars: set[str] = set() + seen_slots: set[tuple[int, int]] = set() + clean: list[dict] = [] + for p in body.placements: + low = p.character_name.lower() + if low not in roles: + raise HTTPException(status_code=400, detail=f"'{scrub(p.character_name)}' is not on the raid roster") + if low in seen_chars: + raise HTTPException(status_code=400, detail=f"'{scrub(p.character_name)}' appears twice") + seen_chars.add(low) + + in_group = p.group_num is not None or p.slot is not None + if p.sitout and in_group: + raise HTTPException(status_code=400, detail="A character can't be in a group and on sitout") + if in_group: + if p.group_num is None or p.slot is None: + raise HTTPException(status_code=400, detail="group_num and slot must be set together") + if not (1 <= p.group_num <= GROUPS) or not (0 <= p.slot < SLOTS_PER_GROUP): + raise HTTPException(status_code=400, detail="group_num must be 1-4 and slot 0-5") + key = (p.group_num, p.slot) + if key in seen_slots: + raise HTTPException(status_code=400, detail=f"Two characters in group {p.group_num} slot {p.slot + 1}") + seen_slots.add(key) + clean.append( + { + "character_name": p.character_name, + "group_num": p.group_num if in_group else None, + "slot": p.slot if in_group else None, + "sitout": p.sitout, + } + ) + + await planning_db.replace_placements(world, guild_name, team_index, clean, updated_by=user["id"]) + audit_log( + "raid_placements_saved", + actor=user["id"], + guild=guild_name, + team_index=team_index, + placed=len(seen_slots), + total=len(clean), + ) + return {"ok": True, "count": len(clean)} + + +# ── Personal availability ──────────────────────────────────────────────────── + + +def _availability_window() -> tuple[dt.date, dt.date]: + today = dt.date.today() + return today, today + dt.timedelta(days=AVAILABILITY_HORIZON_DAYS) + + +@router.get("/me/availability", response_model=AvailabilityResponse) +@limiter.limit("30/minute") +async def get_my_availability(request: Request) -> AvailabilityResponse: + """The viewer's calendar + whether any of their characters is rostered + (drives whether the home-page panel shows at all).""" + user = _require_session(request) + start, end = _availability_window() + + claims_data = await get_active_claims(user["id"], world=current_world()) + my_chars = {c["character_name"].lower() for c in claims_data["approved"]} + roled = await planning_db.roles_for_world(current_world()) + is_raider = bool(my_chars & set(roled.keys())) + + days = await availability_db.get_range(user["id"], start.isoformat(), end.isoformat()) + return AvailabilityResponse(is_raider=is_raider, horizon_days=AVAILABILITY_HORIZON_DAYS, days=days) + + +@router.put("/me/availability") +@limiter.limit("30/minute") +async def put_my_availability(request: Request, body: AvailabilityInput) -> dict: + """Bulk-set the viewer's calendar days within the 3-month window.""" + user = _require_session(request) + start, end = _availability_window() + + validated: dict[str, str] = {} + for day_str, status in body.days.items(): + if status not in ("available", "tentative", "afk"): + raise HTTPException(status_code=400, detail="status must be available, tentative or afk") + try: + day = dt.date.fromisoformat(day_str) + except ValueError as exc: + raise HTTPException(status_code=400, detail=f"bad date {scrub(day_str)!r}") from exc + if not (start <= day <= end): + raise HTTPException( + status_code=400, + detail=f"dates must be within today..+{AVAILABILITY_HORIZON_DAYS} days", + ) + validated[day.isoformat()] = status + + await availability_db.set_days(user["id"], validated) + return {"ok": True, "count": len(validated)} diff --git a/backend/server/api/raid_schedule.py b/backend/server/api/raid_schedule.py index 1af474f2..213e0f87 100644 --- a/backend/server/api/raid_schedule.py +++ b/backend/server/api/raid_schedule.py @@ -233,5 +233,10 @@ async def put_raid_schedule(guild_name: str, body: RaidScheduleInput, request: R world = current_world() await raid_schedule_db.replace_schedule(world, guild_name, db_teams, updated_by=user["id"]) + # Raid-planning layouts are keyed by team_index; drop layouts for team + # indexes that no longer exist after this save. + from backend.server.db.raid_planning import store as raid_planning_db + + await raid_planning_db.prune_placements_beyond(world, guild_name, len(db_teams)) audit_log("raid_schedule_updated", actor=user["id"], guild=guild_name, teams=len(db_teams)) return _fmt_schedule(await raid_schedule_db.get_schedule(world, guild_name)) diff --git a/backend/server/app.py b/backend/server/app.py index 27ab27a9..ebe533e3 100644 --- a/backend/server/app.py +++ b/backend/server/app.py @@ -69,6 +69,7 @@ async def get_response(self, path: str, scope): # type: ignore[override] from backend.server.api.item_watch import router as item_watch_router from backend.server.api.notifications import router as notifications_router from backend.server.api.parses import router as parses_router +from backend.server.api.raid_planning import router as raid_planning_router from backend.server.api.raid_schedule import router as raid_schedule_router from backend.server.api.raid_strategies import router as raid_strategies_router from backend.server.api.rankings import router as rankings_router @@ -603,6 +604,7 @@ async def _parse_cleanup_loop() -> None: guild_router, guild_officer_router, item_watch_router, + raid_planning_router, raid_schedule_router, favorites_router, characters_router, diff --git a/backend/server/db/__init__.py b/backend/server/db/__init__.py index 513727e0..c099ac64 100644 --- a/backend/server/db/__init__.py +++ b/backend/server/db/__init__.py @@ -59,9 +59,11 @@ def init_db(path: Path | None = None) -> None: # re-points one attribute per store and every alias follows. # --------------------------------------------------------------------------- +from backend.server.db.availability import store as availability_store # noqa: E402 from backend.server.db.claims import store as claims_store # noqa: E402 from backend.server.db.favorites import store as favorites_store # noqa: E402 from backend.server.db.item_watch import store as item_watch_store # noqa: E402 +from backend.server.db.raid_planning import store as raid_planning_store # noqa: E402 from backend.server.db.raid_schedule import store as raid_schedule_store # noqa: E402 from backend.server.db.servers import store as servers_store # noqa: E402 from backend.server.db.tokens import store as tokens_store # noqa: E402 @@ -121,9 +123,11 @@ def init_db(path: Path | None = None) -> None: #: Every domain store over users.db — conftest re-points `store.path` on #: each after re-resolving DB_PATH from the env. ALL_STORES = ( + availability_store, claims_store, favorites_store, item_watch_store, + raid_planning_store, raid_schedule_store, servers_store, tokens_store, diff --git a/backend/server/db/availability.py b/backend/server/db/availability.py new file mode 100644 index 00000000..0fa6db47 --- /dev/null +++ b/backend/server/db/availability.py @@ -0,0 +1,62 @@ +"""users.db per-user raid-availability calendar (async aiosqlite). + +Only non-default days are stored — an absent row means Available, so a +player who never touches the calendar is always available (per the raid- +planning design). Global per user: a player is AFK on a date regardless of +which character or guild is involved. + +Per-call connections via the shared ``AsyncStoreBase._db()``; tests +re-point ``store.path``. +""" + +from __future__ import annotations + +from pathlib import Path + +from backend.db_catalogue import AsyncStoreBase +from backend.server.db import DB_PATH +from backend.sql_loader import load_sql + +_SQL = load_sql(__file__) + +#: Statuses that get a row. "available" is the implicit default (row deleted). +STORED_STATUSES = ("tentative", "afk") + + +class AvailabilityStore(AsyncStoreBase): + """users.db `user_availability` domain. Schema/migrations are owned by + the package orchestrator (backend.server.db.init_db).""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + async def get_range(self, discord_id: str, from_day: str, to_day: str) -> dict[str, str]: + """{YYYY-MM-DD: 'tentative'|'afk'} for the window. Absent = available.""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_range"], (discord_id, from_day, to_day)) as cur: + return {r["day"]: r["status"] for r in await cur.fetchall()} + + async def set_days(self, discord_id: str, days: dict[str, str]) -> None: + """Bulk-set days. ``available`` deletes the row (back to default); + ``tentative``/``afk`` upsert. Date-window validation is the route + layer's job (this persists what it's given).""" + async with self._db() as db: + for day, status in days.items(): + if status == "available": + await db.execute(_SQL["delete_day"], (discord_id, day)) + elif status in STORED_STATUSES: + await db.execute(_SQL["upsert_day"], (discord_id, day, status)) + else: + raise ValueError(f"status must be available/tentative/afk, got {status!r}") + await db.commit() + + async def statuses_for_day(self, day: str) -> dict[str, str]: + """{discord_id: status} for every user with a non-default entry on + ``day`` — the raid planner overlays this onto the claims map.""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_statuses_for_day"], (day,)) as cur: + return {r["discord_id"]: r["status"] for r in await cur.fetchall()} + + +# The shared default instance — every runtime consumer goes through this. +store = AvailabilityStore() diff --git a/backend/server/db/availability.sql b/backend/server/db/availability.sql new file mode 100644 index 00000000..52c685ac --- /dev/null +++ b/backend/server/db/availability.sql @@ -0,0 +1,18 @@ +-- SQL for backend/server/db/availability.py (AvailabilityStore). + +-- :name select_range +SELECT day, status FROM user_availability +WHERE discord_id = ? AND day >= ? AND day <= ? +ORDER BY day; + +-- :name upsert_day +INSERT INTO user_availability (discord_id, day, status) +VALUES (?, ?, ?) +ON CONFLICT(discord_id, day) DO UPDATE SET status = excluded.status; + +-- :name delete_day +DELETE FROM user_availability WHERE discord_id = ? AND day = ?; + +-- :name select_statuses_for_day +SELECT discord_id, status FROM user_availability +WHERE day = ?; diff --git a/backend/server/db/raid_planning.py b/backend/server/db/raid_planning.py new file mode 100644 index 00000000..310b6d0d --- /dev/null +++ b/backend/server/db/raid_planning.py @@ -0,0 +1,143 @@ +"""users.db raid-planning helpers (async aiosqlite). + +Two concerns, both officer-curated and guild-member-viewable: + + * ``raid_roster_roles`` — which guild characters are raiders / raid alts + (guild-scoped: a character is on the roster for the guild, then placed + into any team's layout). + * ``raid_placements`` — where each rostered character sits in a team's + 4×6 group grid (or the sitout strip). Keyed by ``team_index`` because + the raid-schedule editor regenerates team rows on save (ids are not + stable); the schedule PUT prunes placements for removed teams. + +Per-call connections via the shared ``AsyncStoreBase._db()``; tests +re-point ``store.path``. +""" + +from __future__ import annotations + +from pathlib import Path + +from backend.db_catalogue import AsyncStoreBase +from backend.server.db import DB_PATH +from backend.sql_loader import load_sql + +_SQL = load_sql(__file__) + +VALID_ROLES = ("raider", "raid_alt") + + +class RaidPlanningStore(AsyncStoreBase): + """users.db `raid_planning` domain. Schema/migrations are owned by the + package orchestrator (backend.server.db.init_db).""" + + def __init__(self, path: Path = DB_PATH) -> None: + super().__init__(path) + + # ── Roster roles ───────────────────────────────────────────────────────── + + async def get_roles(self, world: str, guild_name: str) -> list[dict]: + """All roster designations for a guild: [{character_name, role, ...}].""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_roles"], (world, guild_name)) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def set_role( + self, + world: str, + guild_name: str, + character_name: str, + role: str | None, + updated_by: str, + ) -> None: + """Set (or with ``role=None`` clear) a character's roster designation. + + Clearing the role also removes the character from every team layout — + a character that is no longer a raider can't keep occupying a slot. + """ + if role is not None and role not in VALID_ROLES: + raise ValueError(f"role must be one of {VALID_ROLES}, got {role!r}") + async with self._db() as db: + if role is None: + await db.execute(_SQL["delete_role"], (world, guild_name, character_name)) + await db.execute(_SQL["delete_placements_for_character"], (world, guild_name, character_name)) + else: + await db.execute( + _SQL["upsert_role"], + (world, guild_name, character_name, role, updated_by), + ) + await db.commit() + + # ── Team placements ────────────────────────────────────────────────────── + + async def get_placements(self, world: str, guild_name: str, team_index: int) -> list[dict]: + """One team's layout: [{character_name, group_num, slot, sitout}].""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_placements"], (world, guild_name, team_index)) as cur: + return [dict(r) for r in await cur.fetchall()] + + async def replace_placements( + self, + world: str, + guild_name: str, + team_index: int, + placements: list[dict], + updated_by: str, + ) -> None: + """Transactionally replace a team's whole layout. + + Each placement dict: ``{character_name, group_num|None, slot|None, + sitout}``. Structural validation (groups 1-4, slots 0-5, no double + booking) is the route layer's job; this just persists. + """ + async with self._db() as db: + try: + await db.execute("BEGIN") + await db.execute(_SQL["delete_placements_for_team"], (world, guild_name, team_index)) + for p in placements: + await db.execute( + _SQL["insert_placement"], + ( + world, + guild_name, + team_index, + p["character_name"], + p.get("group_num"), + p.get("slot"), + 1 if p.get("sitout") else 0, + updated_by, + ), + ) + await db.commit() + except Exception: + await db.rollback() + raise + + async def prune_placements_beyond(self, world: str, guild_name: str, team_count: int) -> None: + """Drop layouts for team indexes that no longer exist after a + schedule save (teams removed or the list shortened).""" + async with self._db() as db: + await db.execute(_SQL["prune_placements_beyond"], (world, guild_name, team_count)) + await db.commit() + + async def roles_for_world(self, world: str) -> dict[str, str]: + """{character_name_lower: role} across every guild on the world — + the home-page availability panel checks the viewer's claimed + characters against this to decide whether to show itself.""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_roles_for_world"], (world,)) as cur: + return {r["name_lower"]: r["role"] for r in await cur.fetchall()} + + # ── Player mapping ─────────────────────────────────────────────────────── + + async def claims_map(self, world: str) -> dict[str, str]: + """{character_name_lower: discord_id} for every approved claim on the + world — who plays whom, for availability overlay + the duplicate- + player warning.""" + async with self._db(row_factory=True) as db: + async with db.execute(_SQL["select_claims_for_world"], (world,)) as cur: + return {r["name_lower"]: r["discord_id"] for r in await cur.fetchall()} + + +# The shared default instance — every runtime consumer goes through this. +store = RaidPlanningStore() diff --git a/backend/server/db/raid_planning.sql b/backend/server/db/raid_planning.sql new file mode 100644 index 00000000..8d2e0f8f --- /dev/null +++ b/backend/server/db/raid_planning.sql @@ -0,0 +1,58 @@ +-- SQL for backend/server/db/raid_planning.py (RaidPlanningStore). + +-- :name select_roles +SELECT character_name, role, updated_at, updated_by +FROM raid_roster_roles +WHERE world = ? AND guild_name = ? +ORDER BY LOWER(character_name); + +-- :name upsert_role +INSERT INTO raid_roster_roles (world, guild_name, character_name, role, updated_at, updated_by) +VALUES (?, ?, ?, ?, strftime('%s','now'), ?) +ON CONFLICT(world, guild_name, character_name) DO UPDATE SET + role = excluded.role, + updated_at = excluded.updated_at, + updated_by = excluded.updated_by; + +-- :name delete_role +DELETE FROM raid_roster_roles +WHERE world = ? AND guild_name = ? AND LOWER(character_name) = LOWER(?); + +-- :name delete_placements_for_character +DELETE FROM raid_placements +WHERE world = ? AND guild_name = ? AND LOWER(character_name) = LOWER(?); + +-- :name select_placements +SELECT character_name, group_num, slot, sitout +FROM raid_placements +WHERE world = ? AND guild_name = ? AND team_index = ? +ORDER BY group_num, slot, LOWER(character_name); + +-- :name delete_placements_for_team +DELETE FROM raid_placements +WHERE world = ? AND guild_name = ? AND team_index = ?; + +-- :name insert_placement +INSERT INTO raid_placements + (world, guild_name, team_index, character_name, group_num, slot, sitout, updated_at, updated_by) +VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'), ?); + +-- :name prune_placements_beyond +DELETE FROM raid_placements +WHERE world = ? AND guild_name = ? AND team_index >= ?; + +-- Approved claims for a guild's characters: who plays whom. Availability and +-- the duplicate-player warning both key off this. Case-insensitive join side +-- is handled by lower-casing the input names in Python. +-- :name select_claims_for_world +SELECT LOWER(character_name) AS name_lower, discord_id +FROM character_claims +WHERE world = ? AND status = 'approved'; + +-- Is any of these characters on a raid roster anywhere on this world? +-- Drives the home-page availability panel's is_raider gate. Name matching is +-- case-insensitive via lower-cased input. +-- :name select_roles_for_world +SELECT LOWER(character_name) AS name_lower, role +FROM raid_roster_roles +WHERE world = ?; diff --git a/backend/server/db/schema.sql b/backend/server/db/schema.sql index 8caa035c..73d2404c 100644 --- a/backend/server/db/schema.sql +++ b/backend/server/db/schema.sql @@ -224,3 +224,56 @@ CREATE TABLE IF NOT EXISTS character_favorites ( -- Serves the public favourited-by-N count on character pages. The per-user -- list query is covered by the UNIQUE index prefix (discord_id first). CREATE INDEX IF NOT EXISTS idx_favorites_character ON character_favorites(character_name, world); + +-- ── Raid planning ──────────────────────────────────────────────────────────── +-- Officer-curated raid rosters + per-team group layouts + per-user availability. +-- All three are brand-new tables so CREATE TABLE IF NOT EXISTS needs no +-- migration. Names are stored as they appear in the Census roster; lookups +-- lower-case both sides at the query layer. + +-- Which guild characters are on the raid roster and in what capacity. +-- Guild-scoped (not team-scoped): a character is a raider for the guild and +-- may then be placed into any team's layout. +CREATE TABLE IF NOT EXISTS raid_roster_roles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + world TEXT NOT NULL, + guild_name TEXT NOT NULL, + character_name TEXT NOT NULL, + role TEXT NOT NULL, -- raider or raid_alt + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_by TEXT, -- discord id of the officer + UNIQUE(world, guild_name, character_name) +); + +CREATE INDEX IF NOT EXISTS idx_raid_roles_guild ON raid_roster_roles(world, guild_name); + +-- Where each rostered character sits in a team's 4x6 layout. Keyed by +-- team_index (position in the guild's raid_teams list) rather than team id: +-- replace_schedule regenerates team rows so ids are not stable across +-- schedule edits. Placements for team indexes beyond the current team count +-- are pruned when the schedule is saved. +CREATE TABLE IF NOT EXISTS raid_placements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + world TEXT NOT NULL, + guild_name TEXT NOT NULL, + team_index INTEGER NOT NULL, -- 0..3 + character_name TEXT NOT NULL, + group_num INTEGER, -- 1..4 and NULL when benched or sat out + slot INTEGER, -- 0..5 within the group + sitout INTEGER NOT NULL DEFAULT 0, -- 1 = parked on the sitout strip + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_by TEXT, + UNIQUE(world, guild_name, team_index, character_name) +); + +CREATE INDEX IF NOT EXISTS idx_raid_place_team ON raid_placements(world, guild_name, team_index); + +-- Per-user raid availability calendar. Only non-default days are stored — +-- an absent row means Available. Global per user (not per guild or world): +-- a player is AFK on a date regardless of which character they bring. +CREATE TABLE IF NOT EXISTS user_availability ( + discord_id TEXT NOT NULL REFERENCES users(discord_id) ON DELETE CASCADE, + day TEXT NOT NULL, -- ISO date YYYY-MM-DD + status TEXT NOT NULL, -- tentative or afk + PRIMARY KEY (discord_id, day) +); diff --git a/frontend/src/components/RaidAvailability.tsx b/frontend/src/components/RaidAvailability.tsx new file mode 100644 index 00000000..65c143c7 --- /dev/null +++ b/frontend/src/components/RaidAvailability.tsx @@ -0,0 +1,187 @@ +// "My raid availability" — the personal 3-month calendar raid planners read. +// +// Renders NOTHING unless one of the viewer's claimed characters is on a +// raid roster (is_raider from /api/me/availability), so non-raiders never +// see it. Starts collapsed even for raiders. Clicking a day cycles +// Available → Tentative → AFK → Available; saves are debounced. + +import { useEffect, useMemo, useRef, useState } from 'react' + +import { toErrorMessage } from '../lib/errors' + +type DayStatus = 'available' | 'tentative' | 'afk' + +interface AvailabilityData { + is_raider: boolean + horizon_days: number + days: Record +} + +const CYCLE: Record = { available: 'tentative', tentative: 'afk', afk: 'available' } + +const STATUS_STYLE: Record = { + available: 'bg-success/15 text-success border-success/30', + tentative: 'bg-warning/20 text-warning border-warning/40', + afk: 'bg-danger/20 text-danger border-danger/40', +} + +function iso(d: Date): string { + return d.toISOString().slice(0, 10) +} + +/** The months (as first-of-month Dates) covering today..today+horizon. */ +function monthsInWindow(horizonDays: number): Date[] { + const out: Date[] = [] + const today = new Date() + const end = new Date(today) + end.setDate(end.getDate() + horizonDays) + const cur = new Date(today.getFullYear(), today.getMonth(), 1) + while (cur <= end) { + out.push(new Date(cur)) + cur.setMonth(cur.getMonth() + 1) + } + return out +} + +export default function RaidAvailability() { + const [data, setData] = useState(null) + const [open, setOpen] = useState(false) + const [days, setDays] = useState>({}) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [error, setError] = useState(null) + + useEffect(() => { + fetch('/api/me/availability', { credentials: 'include' }) + .then(async res => { + if (!res.ok) return + const d = (await res.json()) as AvailabilityData + setData(d) + setDays(d.days) + }) + .catch(() => {}) + }, []) + + // debounced save of pending day changes + const pending = useRef>({}) + const timer = useRef | null>(null) + + function cycleDay(day: string) { + const current: DayStatus = days[day] ?? 'available' + const next = CYCLE[current] + setDays(prev => { + const copy = { ...prev } + if (next === 'available') delete copy[day] + else copy[day] = next + return copy + }) + pending.current[day] = next + if (timer.current) clearTimeout(timer.current) + setSaveState('saving') + timer.current = setTimeout(async () => { + const batch = pending.current + pending.current = {} + try { + const res = await fetch('/api/me/availability', { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days: batch }), + }) + if (!res.ok) { + setSaveState('error') + setError((await res.json().catch(() => ({}))).detail ?? 'Save failed') + return + } + setSaveState('saved') + setError(null) + } catch (err) { + setSaveState('error') + setError(toErrorMessage(err)) + } + }, 600) + } + + const months = useMemo(() => (data ? monthsInWindow(data.horizon_days) : []), [data]) + const todayIso = iso(new Date()) + const endIso = useMemo(() => { + if (!data) return todayIso + const e = new Date() + e.setDate(e.getDate() + data.horizon_days) + return iso(e) + }, [data, todayIso]) + + if (!data?.is_raider) return null + + return ( +
+ + + {open && ( +
+

+ Click a day to cycle{' '} + Available →{' '} + Tentative →{' '} + AFK. Days you don't touch count as available. Raid leaders see + this when planning groups. +

+ {error &&

{error}

} +
+ {months.map(m => { + const year = m.getFullYear() + const month = m.getMonth() + const firstDow = (new Date(year, month, 1).getDay() + 6) % 7 // Mon=0 + const daysInMonth = new Date(year, month + 1, 0).getDate() + return ( +
+

+ {m.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })} +

+
+ {['M', 'T', 'W', 'T', 'F', 'S', 'S'].map((d, i) => ( + {d} + ))} + {Array.from({ length: firstDow }, (_, i) => )} + {Array.from({ length: daysInMonth }, (_, i) => { + const day = iso(new Date(Date.UTC(year, month, i + 1))) + const inWindow = day >= todayIso && day <= endIso + const status: DayStatus = days[day] ?? 'available' + return ( + + ) + })} +
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index f3dd9b5c..2d9ae293 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -6,6 +6,7 @@ import type { Claim } from '../hooks/useClaim' import ServerLaunchTimer from '../components/ServerLaunchTimer' import CharacterSummaryCard from '../components/CharacterSummaryCard' import FavoritesSection from '../components/FavoritesSection' +import RaidAvailability from '../components/RaidAvailability' import { Card } from '../components/ui' // logo.webp lives in frontend/public/ → served at site root by Vite const logo = '/logo.webp' @@ -218,6 +219,7 @@ export default function HomePage() { {/* Characters */} {auth.status === 'authenticated' && } + {auth.status === 'authenticated' && } {/* Favourites — bookmarks, independent of claims; hidden when empty */} {auth.status === 'authenticated' && ( diff --git a/frontend/src/pages/guild/GuildRaidScheduleTab.test.tsx b/frontend/src/pages/guild/GuildRaidScheduleTab.test.tsx index 7969a3b8..84f0dc59 100644 --- a/frontend/src/pages/guild/GuildRaidScheduleTab.test.tsx +++ b/frontend/src/pages/guild/GuildRaidScheduleTab.test.tsx @@ -6,7 +6,15 @@ import { GuildRaidScheduleTab } from './GuildRaidScheduleTab' function mockFetch(teams: unknown[]) { vi.stubGlobal( 'fetch', - vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ teams }) })) as unknown as typeof fetch, + vi.fn(async (url: string) => { + if (String(url).includes('/api/classes')) { + return { ok: true, status: 200, json: async () => [] } + } + if (String(url).includes('/raid-planning/')) { + return { ok: false, status: 403, json: async () => ({ detail: 'members only' }) } + } + return { ok: true, status: 200, json: async () => ({ teams }) } + }) as unknown as typeof fetch, ) } diff --git a/frontend/src/pages/guild/GuildRaidScheduleTab.tsx b/frontend/src/pages/guild/GuildRaidScheduleTab.tsx index 82877630..bee1c8e2 100644 --- a/frontend/src/pages/guild/GuildRaidScheduleTab.tsx +++ b/frontend/src/pages/guild/GuildRaidScheduleTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Button, Card, SectionLabel, Badge } from '../../components/ui' +import { RaidPlanner } from './raidplanner/RaidPlanner' import { toErrorMessage } from '../../lib/errors' import { WEEKDAYS, @@ -50,6 +51,7 @@ function viewerRange(startMin: number, endMin: number, teamTz: string): string { export function GuildRaidScheduleTab({ guildName, isOfficer }: { guildName: string; isOfficer: boolean }) { const [teams, setTeams] = useState(null) + const [expanded, setExpanded] = useState>(new Set()) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -64,12 +66,24 @@ export function GuildRaidScheduleTab({ guildName, isOfficer }: { guildName: stri fetch(`/api/guild/${encodeURIComponent(guildName)}/raid-schedule`, { credentials: 'include' }) .then(async res => { if (!res.ok) { setError((await res.json().catch(() => ({}))).detail ?? `Error ${res.status}`); return } - setTeams((await res.json()).teams as RaidTeam[]) + const loaded = (await res.json()).teams as RaidTeam[] + setTeams(loaded) + // A single team starts expanded; multiple teams start collapsed so + // the tab stays scannable. + setExpanded(loaded.length === 1 ? new Set([0]) : new Set()) }) .catch(err => setError(toErrorMessage(err))) .finally(() => setLoading(false)) }, [guildName]) + const toggleExpand = (i: number) => + setExpanded(prev => { + const next = new Set(prev) + if (next.has(i)) next.delete(i) + else next.add(i) + return next + }) + function startEdit() { setDraft((teams ?? []).map(toEdit)) setSaveError(null) @@ -133,35 +147,52 @@ export function GuildRaidScheduleTab({ guildName, isOfficer }: { guildName: stri
{teams.map((t, i) => ( -
+
- {t.raids.length === 0 ? ( -

No raids set.

- ) : ( -
- {t.raids.map((r, j) => ( -
- - {WEEKDAYS.map(d => ( - - {d.label} + + {expanded.has(i) && ( +
+ {t.raids.length === 0 ? ( +

No raids set.

+ ) : ( +
+ {t.raids.map((r, j) => ( +
+ + {WEEKDAYS.map(d => ( + + {d.label} + + ))} + + + {minutesToHHMM(r.start_min)}–{minutesToHHMM(r.end_min)} - ))} - - - {minutesToHHMM(r.start_min)}–{minutesToHHMM(r.end_min)} - - ({viewerRange(r.start_min, r.end_min, t.primary_tz)}) - {r.label && {r.label}} + ({viewerRange(r.start_min, r.end_min, t.primary_tz)}) + {r.label && {r.label}} +
+ ))}
- ))} + )} + r.days)} + />
)} diff --git a/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx new file mode 100644 index 00000000..4f1c7da2 --- /dev/null +++ b/frontend/src/pages/guild/raidplanner/RaidPlanner.tsx @@ -0,0 +1,457 @@ +// The per-team raid planner: 4 groups × 6 slots + sitout + bench, with +// archetype-coloured character chips, drag-and-drop (click-to-place +// fallback), per-group composition warnings and an availability overlay +// for the selected date. +// +// Officers' changes save automatically (debounced). Non-officer guild +// members get a local sandbox: they can drag everything around, nothing +// persists, and a reset chip restores the live plan. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { Badge, Button, SectionLabel } from '../../../components/ui' +import { toErrorMessage } from '../../../lib/errors' +import { useClasses } from '../../../useClasses' +import { + buildGrid, + computeWarnings, + moveCharacter, + nextRaidDate, + placementsEqual, + swapGroups, +} from './logic' +import { AVAILABILITY_LABEL, GROUPS, SLOTS_PER_GROUP } from './types' +import type { DropTarget, Placement, PlannerData, RosterEntry } from './types' + +// ── Character chip ─────────────────────────────────────────────────────────── + +function CharChip({ + entry, + availability, + player, + colour, + selected, + interactive, + onPick, + onDragStart, +}: { + entry: RosterEntry + availability: 'tentative' | 'afk' | undefined + player: string | undefined + colour: string + selected: boolean + interactive: boolean + onPick?: () => void + onDragStart?: (e: React.DragEvent) => void +}) { + const afk = availability === 'afk' + return ( + + ) +} + +// ── Planner ────────────────────────────────────────────────────────────────── + +export function RaidPlanner({ guildName, teamIndex, raidDays }: { + guildName: string + teamIndex: number + raidDays: number[][] +}) { + const { byName, colourFor } = useClasses() + + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [forbidden, setForbidden] = useState(false) + const [date, setDate] = useState(() => nextRaidDate(raidDays)) + + // Working copy of placements (officer: autosaves; member: sandbox). + const [placements, setPlacements] = useState([]) + const savedRef = useRef([]) + const [dirtySandbox, setDirtySandbox] = useState(false) + const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') + const [saveError, setSaveError] = useState(null) + + // click-to-place selection (keyboard/mobile fallback for drag & drop) + const [selected, setSelected] = useState(null) + const [managing, setManaging] = useState(false) + const [rosterFilter, setRosterFilter] = useState('') + + const load = useCallback(() => { + setError(null) + fetch( + `/api/guild/${encodeURIComponent(guildName)}/raid-planning/${teamIndex}?date=${date}`, + { credentials: 'include' }, + ) + .then(async res => { + if (res.status === 401 || res.status === 403) { setForbidden(true); return } + if (!res.ok) { setError((await res.json().catch(() => ({}))).detail ?? `Error ${res.status}`); return } + const d = (await res.json()) as PlannerData + setData(d) + setPlacements(d.placements) + savedRef.current = d.placements + setDirtySandbox(false) + }) + .catch(err => setError(toErrorMessage(err))) + }, [guildName, teamIndex, date]) + + useEffect(load, [load]) + + // ── officer autosave (debounced) ── + const saveTimer = useRef | null>(null) + useEffect(() => { + if (!data?.is_officer) return + if (placementsEqual(placements, savedRef.current)) return + if (saveTimer.current) clearTimeout(saveTimer.current) + setSaveState('saving') + const toSave = placements + saveTimer.current = setTimeout(async () => { + try { + const res = await fetch( + `/api/guild/${encodeURIComponent(guildName)}/raid-planning/${teamIndex}/placements`, + { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ placements: toSave }), + }, + ) + if (!res.ok) { + setSaveState('error') + setSaveError((await res.json().catch(() => ({}))).detail ?? 'Save failed') + return + } + savedRef.current = toSave + setSaveState('saved') + setSaveError(null) + } catch (err) { + setSaveState('error') + setSaveError(toErrorMessage(err)) + } + }, 700) + return () => { if (saveTimer.current) clearTimeout(saveTimer.current) } + }, [placements, data?.is_officer, guildName, teamIndex]) + + // ── derived state ── + const roled = useMemo(() => (data?.roster ?? []).filter(r => r.role), [data]) + const rosterByLower = useMemo(() => { + const m = new Map() + for (const r of data?.roster ?? []) m.set(r.name.toLowerCase(), r) + return m + }, [data]) + const grid = useMemo( + () => buildGrid(placements, roled.filter(r => r.role === 'raider').map(r => r.name)), + [placements, roled], + ) + const altBench = useMemo(() => { + const placed = new Set(placements.map(p => p.character_name.toLowerCase())) + return roled.filter(r => r.role === 'raid_alt' && !placed.has(r.name.toLowerCase())) + }, [placements, roled]) + const classByChar = useMemo(() => { + const m = new Map>() + for (const r of roled) m.set(r.name.toLowerCase(), r.cls ? byName.get(r.cls) : undefined) + return m + }, [roled, byName]) + const warnings = useMemo( + () => (data ? computeWarnings(data, placements, classByChar) : []), + [data, placements, classByChar], + ) + + const interactive = Boolean(data) // everyone can move; officers persist, members sandbox + const isOfficer = Boolean(data?.is_officer) + + // ── movement ── + function applyMove(name: string, target: DropTarget) { + setPlacements(prev => { + const next = moveCharacter(prev, name, target) + if (!isOfficer && !placementsEqual(next, savedRef.current)) setDirtySandbox(true) + return next + }) + setSelected(null) + } + + function dropHandler(target: DropTarget) { + return (e: React.DragEvent) => { + e.preventDefault() + const name = e.dataTransfer.getData('text/x-character') + const group = e.dataTransfer.getData('text/x-group') + if (group && target.kind === 'slot') { + setPlacements(prev => swapGroups(prev, Number(group), target.group)) + return + } + if (name) applyMove(name, target) + } + } + + const dragChip = (name: string) => (e: React.DragEvent) => { + e.dataTransfer.setData('text/x-character', name) + e.dataTransfer.effectAllowed = 'move' + } + const dragGroup = (group: number) => (e: React.DragEvent) => { + e.dataTransfer.setData('text/x-group', String(group)) + e.dataTransfer.effectAllowed = 'move' + } + const allowDrop = (e: React.DragEvent) => e.preventDefault() + + function resetSandbox() { + setPlacements(savedRef.current) + setDirtySandbox(false) + } + + async function toggleRole(name: string, role: 'raider' | 'raid_alt' | null) { + try { + const res = await fetch(`/api/guild/${encodeURIComponent(guildName)}/raid-planning/roles`, { + method: 'PUT', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ character_name: name, role }), + }) + if (!res.ok) { + setSaveError((await res.json().catch(() => ({}))).detail ?? 'Failed to update role') + return + } + load() + } catch (err) { + setSaveError(toErrorMessage(err)) + } + } + + // ── render helpers ── + const chipFor = (name: string) => { + const entry = rosterByLower.get(name.toLowerCase()) + if (!entry) return null + return ( + setSelected(s => (s?.toLowerCase() === name.toLowerCase() ? null : name))} + onDragStart={dragChip(name)} + /> + ) + } + + const slotClasses = (occupied: boolean) => + `min-h-[38px] rounded-sm border ${ + occupied ? 'border-border/60 bg-surface' : 'border-dashed border-border/70 bg-surface/40' + } ${selected ? 'hover:border-gold cursor-pointer' : ''}` + + if (forbidden) return null // not a guild member — planner simply doesn't exist for them + if (error) return

{error}

+ if (!data) return

Loading raid plan…

+ + const groupCount = (g: number) => grid.groups[g - 1].filter(Boolean).length + const placedTotal = grid.groups.flat().filter(Boolean).length + + return ( +
+ {/* header row: title, date, status chips */} +
+ Raid Plan + {placedTotal}/24 + + {isOfficer && saveState === 'saving' && Saving…} + {isOfficer && saveState === 'saved' && Saved} + {isOfficer && saveState === 'error' && Save failed} + {!isOfficer && dirtySandbox && ( + + Sandbox — not saved + + + )} +
+ + {saveError &&

{saveError}

} + {selected && ( +

+ Placing {selected} — click a slot, the sitout strip or the bench. Click the character again to cancel. +

+ )} + + {/* warnings */} + {warnings.length > 0 && ( +
+ {warnings.map((w, i) => ( +

+ {w.severity === 'warn' ? '⚠ ' : 'ℹ '} + {w.text} +

+ ))} +
+ )} + + {/* the 4 groups */} +
+ {Array.from({ length: GROUPS }, (_, gi) => gi + 1).map(g => ( +
+
+ Group {g} + {groupCount(g)}/{SLOTS_PER_GROUP} +
+ {Array.from({ length: SLOTS_PER_GROUP }, (_, si) => { + const p = grid.groups[g - 1][si] + return ( +
applyMove(selected, { kind: 'slot', group: g, slot: si }) : undefined} + className={slotClasses(Boolean(p))} + > + {p ? chipFor(p.character_name) : ( + empty + )} +
+ ) + })} +
+ ))} +
+ + {/* sitout strip */} +
applyMove(selected, { kind: 'sitout' }) : undefined} + className={`border border-border rounded-md p-2 ${selected ? 'hover:border-gold cursor-pointer' : ''}`} + > +
+ Sitout + bench for tonight — still raiders +
+
+ {grid.sitout.map(p => ( +
{chipFor(p.character_name)}
+ ))} + {grid.sitout.length === 0 && ( + nobody sitting out + )} +
+
+ + {/* bench: rostered raiders not placed + raid alts */} +
applyMove(selected, { kind: 'bench' }) : undefined} + className={`border border-dashed border-border rounded-md p-2 ${selected ? 'hover:border-gold cursor-pointer' : ''}`} + > +
+ Unassigned + + raiders without a spot{altBench.length > 0 ? ' · raid alts listed after' : ''} + + {isOfficer && ( + + )} +
+
+ {grid.bench.map(n => ( +
{chipFor(n)}
+ ))} + {altBench.map(r => ( +
{chipFor(r.name)}
+ ))} + {grid.bench.length === 0 && altBench.length === 0 && ( + everyone is placed + )} +
+
+ + {/* officer: manage which guild members are raiders / alts */} + {isOfficer && managing && ( +
+
+ Roster designations + setRosterFilter(e.target.value)} + placeholder="filter members…" + className="bg-surface border border-border rounded-sm px-2 py-1 text-[0.8rem] ml-auto w-[160px]" + /> +
+
+ {data.roster + .filter(r => r.name.toLowerCase().includes(rosterFilter.toLowerCase())) + .map(r => ( +
+ + + {r.name} + {r.cls ?? ''}{r.level ? ` ${r.level}` : ''} + + {(['raider', 'raid_alt'] as const).map(role => ( + + ))} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/guild/raidplanner/logic.test.ts b/frontend/src/pages/guild/raidplanner/logic.test.ts new file mode 100644 index 00000000..8fb4d3fd --- /dev/null +++ b/frontend/src/pages/guild/raidplanner/logic.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' + +import type { ClassInfo } from '../../../useClasses' +import { buildGrid, computeWarnings, moveCharacter, nextRaidDate, placementsEqual, swapGroups } from './logic' +import type { Placement } from './types' + +const P = (name: string, group: number | null, slot: number | null, sitout = false): Placement => ({ + character_name: name, + group_num: group, + slot, + sitout, +}) + +const cls = (name: string, archetype: string, subclass: string | null): ClassInfo => ({ + name, + archetype, + subclass, + role: 'x', + colour: '#fff', + display_order: 0, + icon_url: '', +}) + +const CLASSMAP = new Map([ + ['tanky', cls('Guardian', 'Fighter', 'Warrior')], + ['healy', cls('Templar', 'Priest', 'Cleric')], + ['bardy', cls('Troubador', 'Scout', 'Bard')], + ['chanty', cls('Illusionist', 'Mage', 'Enchanter')], + ['nukey', cls('Wizard', 'Mage', 'Sorcerer')], +]) + +describe('moveCharacter', () => { + it('places a benched character into an empty slot', () => { + const next = moveCharacter([], 'Tanky', { kind: 'slot', group: 1, slot: 0 }) + expect(next).toEqual([P('Tanky', 1, 0)]) + }) + + it('swaps when dropping onto an occupied slot', () => { + const start = [P('Tanky', 1, 0), P('Healy', 2, 3)] + const next = moveCharacter(start, 'Tanky', { kind: 'slot', group: 2, slot: 3 }) + const byName = Object.fromEntries(next.map(p => [p.character_name, p])) + expect(byName['Tanky']).toMatchObject({ group_num: 2, slot: 3 }) + expect(byName['Healy']).toMatchObject({ group_num: 1, slot: 0 }) + }) + + it('bench→occupied slot sends the occupant to the bench', () => { + const start = [P('Healy', 1, 0)] + const next = moveCharacter(start, 'Tanky', { kind: 'slot', group: 1, slot: 0 }) + expect(next).toEqual([P('Tanky', 1, 0)]) // Healy has no row = benched + }) + + it('sitout→occupied slot sends the occupant to sitout', () => { + const start = [P('Tanky', null, null, true), P('Healy', 1, 0)] + const next = moveCharacter(start, 'Tanky', { kind: 'slot', group: 1, slot: 0 }) + const byName = Object.fromEntries(next.map(p => [p.character_name, p])) + expect(byName['Tanky']).toMatchObject({ group_num: 1, slot: 0, sitout: false }) + expect(byName['Healy']).toMatchObject({ sitout: true }) + }) + + it('moves to sitout and bench', () => { + let next = moveCharacter([P('Tanky', 1, 0)], 'Tanky', { kind: 'sitout' }) + expect(next).toEqual([P('Tanky', null, null, true)]) + next = moveCharacter(next, 'Tanky', { kind: 'bench' }) + expect(next).toEqual([]) + }) + + it('is case-insensitive on names', () => { + const next = moveCharacter([P('Tanky', 1, 0)], 'tAnKy', { kind: 'sitout' }) + expect(next).toEqual([P('tAnKy', null, null, true)]) + }) +}) + +describe('swapGroups', () => { + it('swaps every member of two groups and leaves others alone', () => { + const start = [P('Tanky', 1, 0), P('Healy', 1, 1), P('Nukey', 2, 0), P('Bardy', 3, 5)] + const next = swapGroups(start, 1, 2) + const byName = Object.fromEntries(next.map(p => [p.character_name, p.group_num])) + expect(byName).toEqual({ Tanky: 2, Healy: 2, Nukey: 1, Bardy: 3 }) + }) +}) + +describe('buildGrid', () => { + it('splits placements into groups, sitout and bench', () => { + const placements = [P('Tanky', 1, 0), P('Healy', null, null, true)] + const grid = buildGrid(placements, ['Tanky', 'Healy', 'Bardy']) + expect(grid.groups[0][0]?.character_name).toBe('Tanky') + expect(grid.sitout.map(p => p.character_name)).toEqual(['Healy']) + expect(grid.bench).toEqual(['Bardy']) + }) +}) + +describe('computeWarnings', () => { + const data = { availability: {} as Record, players: {} as Record } + + it('flags missing priest, bard and enchanter per non-empty group', () => { + const w = computeWarnings(data, [P('Nukey', 1, 0)], CLASSMAP) + const texts = w.map(x => x.text) + expect(texts).toContain('Group 1 has no priest') + expect(texts).toContain('Group 1 has no bard') + expect(texts).toContain('Group 1 has no enchanter') + }) + + it('is silent for a group with priest + bard + enchanter and for empty groups', () => { + const w = computeWarnings(data, [P('Healy', 1, 0), P('Bardy', 1, 1), P('Chanty', 1, 2)], CLASSMAP) + expect(w).toEqual([]) + }) + + it('flags AFK characters placed in groups but not on sitout', () => { + const d = { ...data, availability: { tanky: 'afk' as const, healy: 'afk' as const } } + const w = computeWarnings(d, [P('Tanky', 1, 0), P('Healy', null, null, true), P('Bardy', 1, 1), P('Chanty', 1, 2)], CLASSMAP) + const texts = w.map(x => x.text) + expect(texts).toContain('Tanky is AFK on this date') + expect(texts.some(t => t.includes('Healy is AFK'))).toBe(false) + }) + + it('flags the same player fielding two characters in groups', () => { + const d = { ...data, players: { tanky: 'Ben', nukey: 'Ben', healy: 'Sue' } } + const w = computeWarnings( + d, + [P('Tanky', 1, 0), P('Nukey', 2, 0), P('Healy', 1, 1), P('Bardy', 1, 2), P('Chanty', 1, 3), P('Bardy2', 2, 1)], + CLASSMAP, + ) + const dupe = w.find(x => x.text.includes('Ben has 2 characters')) + expect(dupe).toBeTruthy() + expect(dupe!.severity).toBe('info') + }) +}) + +describe('nextRaidDate', () => { + it('returns today when today is a raid day', () => { + const wed = new Date('2026-07-15T12:00:00Z') // a Wednesday + expect(nextRaidDate([[3]], wed)).toBe('2026-07-15') + }) + + it('finds the next matching weekday', () => { + const wed = new Date('2026-07-15T12:00:00Z') + expect(nextRaidDate([[5]], wed)).toBe('2026-07-17') // Friday + expect(nextRaidDate([[1]], wed)).toBe('2026-07-20') // Monday + }) + + it('handles ISO Sunday (7) and falls back to today with no raids', () => { + const sat = new Date('2026-07-18T12:00:00Z') + expect(nextRaidDate([[7]], sat)).toBe('2026-07-19') + expect(nextRaidDate([], sat)).toBe('2026-07-18') + }) +}) + +describe('placementsEqual', () => { + it('ignores order and matches on content', () => { + const a = [P('Tanky', 1, 0), P('Healy', null, null, true)] + const b = [P('Healy', null, null, true), P('Tanky', 1, 0)] + expect(placementsEqual(a, b)).toBe(true) + expect(placementsEqual(a, [P('Tanky', 1, 1), P('Healy', null, null, true)])).toBe(false) + }) +}) diff --git a/frontend/src/pages/guild/raidplanner/logic.ts b/frontend/src/pages/guild/raidplanner/logic.ts new file mode 100644 index 00000000..84596122 --- /dev/null +++ b/frontend/src/pages/guild/raidplanner/logic.ts @@ -0,0 +1,187 @@ +// Pure planner logic — placement moves, group swaps, warnings, next raid +// date. Kept free of React so it's unit-testable (logic.test.ts). + +import type { ClassInfo } from '../../../useClasses' +import { GROUPS, SLOTS_PER_GROUP } from './types' +import type { DropTarget, Placement, PlannerData } from './types' + +// ── Placement moves ────────────────────────────────────────────────────────── + +function without(placements: Placement[], name: string): Placement[] { + return placements.filter(p => p.character_name.toLowerCase() !== name.toLowerCase()) +} + +function findAt(placements: Placement[], group: number, slot: number): Placement | undefined { + return placements.find(p => p.group_num === group && p.slot === slot) +} + +export function findPlacement(placements: Placement[], name: string): Placement | undefined { + return placements.find(p => p.character_name.toLowerCase() === name.toLowerCase()) +} + +/** + * Move a character to a drop target, returning the new placements list. + * Dropping onto an occupied slot SWAPS: the occupant takes the mover's old + * spot (or the bench if the mover came from the bench/sitout). + */ +export function moveCharacter(placements: Placement[], name: string, target: DropTarget): Placement[] { + const mover = findPlacement(placements, name) + let next = without(placements, name) + + if (target.kind === 'bench') { + // Bench = rostered but unplaced: represented by NO placement row. + return next + } + + if (target.kind === 'sitout') { + next = [...next, { character_name: name, group_num: null, slot: null, sitout: true }] + return next + } + + const occupant = findAt(next, target.group, target.slot) + if (occupant) { + next = without(next, occupant.character_name) + if (mover && mover.group_num !== null && mover.slot !== null) { + // swap into the mover's old slot + next = [ + ...next, + { character_name: occupant.character_name, group_num: mover.group_num, slot: mover.slot, sitout: false }, + ] + } else if (mover?.sitout) { + next = [...next, { character_name: occupant.character_name, group_num: null, slot: null, sitout: true }] + } + // mover came from bench → occupant goes to bench (no row) + } + next = [...next, { character_name: name, group_num: target.group, slot: target.slot, sitout: false }] + return next +} + +/** Swap every placement of two groups (drag one group header onto another). */ +export function swapGroups(placements: Placement[], groupA: number, groupB: number): Placement[] { + if (groupA === groupB) return placements + return placements.map(p => { + if (p.group_num === groupA) return { ...p, group_num: groupB } + if (p.group_num === groupB) return { ...p, group_num: groupA } + return p + }) +} + +// ── Derived views ──────────────────────────────────────────────────────────── + +export interface GridView { + groups: (Placement | null)[][] // [group 0..3][slot 0..5] + sitout: Placement[] + bench: string[] // rostered character names with no placement row +} + +export function buildGrid(placements: Placement[], rosteredNames: string[]): GridView { + const groups: (Placement | null)[][] = Array.from({ length: GROUPS }, () => + Array.from({ length: SLOTS_PER_GROUP }, () => null), + ) + const sitout: Placement[] = [] + const placed = new Set() + for (const p of placements) { + placed.add(p.character_name.toLowerCase()) + if (p.sitout) sitout.push(p) + else if (p.group_num !== null && p.slot !== null) { + if (p.group_num >= 1 && p.group_num <= GROUPS && p.slot >= 0 && p.slot < SLOTS_PER_GROUP) { + groups[p.group_num - 1][p.slot] = p + } + } + } + const bench = rosteredNames.filter(n => !placed.has(n.toLowerCase())) + return { groups, sitout, bench } +} + +// ── Warnings ───────────────────────────────────────────────────────────────── + +export interface GroupWarning { + group: number // 1..4 (0 = raid-wide) + text: string + severity: 'warn' | 'info' +} + +/** + * The "useful info" strip. Data-driven off /api/classes: + * - every non-empty group should have ≥1 Priest, ≥1 Bard (subclass) and + * ≥1 Enchanter (subclass) + * - AFK characters placed in groups + * - the same player fielding 2+ characters across groups + */ +export function computeWarnings( + data: Pick, + placements: Placement[], + classByChar: Map, +): GroupWarning[] { + const warnings: GroupWarning[] = [] + const inGroups = placements.filter(p => !p.sitout && p.group_num !== null) + + for (let g = 1; g <= GROUPS; g++) { + const members = inGroups.filter(p => p.group_num === g) + if (members.length === 0) continue + const infos = members.map(p => classByChar.get(p.character_name.toLowerCase())) + const has = (pred: (c: ClassInfo) => boolean) => infos.some(c => c && pred(c)) + + if (!has(c => c.archetype === 'Priest')) { + warnings.push({ group: g, severity: 'warn', text: `Group ${g} has no priest` }) + } + if (!has(c => c.subclass === 'Bard')) { + warnings.push({ group: g, severity: 'warn', text: `Group ${g} has no bard` }) + } + if (!has(c => c.subclass === 'Enchanter')) { + warnings.push({ group: g, severity: 'warn', text: `Group ${g} has no enchanter` }) + } + for (const p of members) { + if (data.availability[p.character_name.toLowerCase()] === 'afk') { + warnings.push({ group: g, severity: 'warn', text: `${p.character_name} is AFK on this date` }) + } + } + } + + // Same player fielding multiple characters in groups. + const byPlayer = new Map() + for (const p of inGroups) { + const player = data.players[p.character_name.toLowerCase()] + if (!player) continue + byPlayer.set(player, [...(byPlayer.get(player) ?? []), p.character_name]) + } + for (const [player, chars] of byPlayer) { + if (chars.length > 1) { + warnings.push({ + group: 0, + severity: 'info', + text: `${player} has ${chars.length} characters in the raid (${chars.join(', ')})`, + }) + } + } + + return warnings +} + +// ── Date helpers ───────────────────────────────────────────────────────────── + +/** + * The next date (today included) that lands on one of the team's raid + * weekdays. Raid slot days use ISO weekdays 1=Mon..7=Sun. Falls back to + * today when the team has no raids configured. + */ +export function nextRaidDate(raidDays: number[][], from: Date = new Date()): string { + const all = new Set(raidDays.flat()) + const iso = (d: Date) => d.toISOString().slice(0, 10) + if (all.size === 0) return iso(from) + for (let i = 0; i < 7; i++) { + const d = new Date(from) + d.setDate(from.getDate() + i) + const isoDay = d.getDay() === 0 ? 7 : d.getDay() // JS 0=Sun → ISO 7 + if (all.has(isoDay)) return iso(d) + } + return iso(from) +} + +/** Serialisable comparison for the debounced-save dirty check. */ +export function placementsEqual(a: Placement[], b: Placement[]): boolean { + const key = (p: Placement) => `${p.character_name.toLowerCase()}|${p.group_num}|${p.slot}|${p.sitout ? 1 : 0}` + const sa = [...a].map(key).sort() + const sb = [...b].map(key).sort() + return sa.length === sb.length && sa.every((v, i) => v === sb[i]) +} diff --git a/frontend/src/pages/guild/raidplanner/types.ts b/frontend/src/pages/guild/raidplanner/types.ts new file mode 100644 index 00000000..23c4b58b --- /dev/null +++ b/frontend/src/pages/guild/raidplanner/types.ts @@ -0,0 +1,40 @@ +// Shared types for the raid planner (pages/guild/raidplanner/). + +export interface RosterEntry { + name: string + cls: string | null + level: number | null + role: 'raider' | 'raid_alt' | null +} + +export interface Placement { + character_name: string + group_num: number | null // 1..4 + slot: number | null // 0..5 + sitout: boolean +} + +export interface PlannerData { + is_officer: boolean + team_index: number + team_count: number + date: string + roster: RosterEntry[] + placements: Placement[] + availability: Record // char name (lower) → status + players: Record // char name (lower) → player display name +} + +/** Where a character chip can be dropped. */ +export type DropTarget = + | { kind: 'slot'; group: number; slot: number } + | { kind: 'sitout' } + | { kind: 'bench' } + +export const GROUPS = 4 +export const SLOTS_PER_GROUP = 6 + +export const AVAILABILITY_LABEL: Record = { + afk: 'AFK', + tentative: 'Tentative', +} diff --git a/frontend/src/useClasses.ts b/frontend/src/useClasses.ts index 99f13c17..b719a866 100644 --- a/frontend/src/useClasses.ts +++ b/frontend/src/useClasses.ts @@ -21,8 +21,11 @@ function loadClasses(): Promise { _inflight = fetch('/api/classes', { credentials: 'include' }) .then(r => (r.ok ? r.json() : Promise.reject(new Error(`/api/classes ${r.status}`)))) .then((data: ClassInfo[]) => { - _cache = data - return data + // Defensive: a misbehaving proxy/mock returning a non-array must not + // crash every consumer with `classes.map is not a function`. + const safe = Array.isArray(data) ? data : [] + _cache = safe + return safe }) .catch(() => { _inflight = null // allow a retry on the next mount diff --git a/tests/server/test_db_facade.py b/tests/server/test_db_facade.py index 4d9ae0d1..acd070c6 100644 --- a/tests/server/test_db_facade.py +++ b/tests/server/test_db_facade.py @@ -39,6 +39,17 @@ "get_schedule", "replace_schedule", "list_all_teams_with_twitch", + # raid_planning + availability domains bypass the facade too + "get_roles", + "set_role", + "get_placements", + "replace_placements", + "prune_placements_beyond", + "claims_map", + "roles_for_world", + "get_range", + "set_days", + "statuses_for_day", # base-class surface "clear_caches", } diff --git a/tests/server/test_raid_planning.py b/tests/server/test_raid_planning.py new file mode 100644 index 00000000..d6d17790 --- /dev/null +++ b/tests/server/test_raid_planning.py @@ -0,0 +1,431 @@ +"""Raid-planning DB layer + API tests. + +DB layer runs against a temp users.db (stores re-pointed by the autouse +fixture). The API is tested with the ``app`` fixture, a signed session +cookie for auth, and mocked ``_officer_chars`` / ``_roster_rank_map`` / +roster helpers (same pattern as test_raid_schedule.py). +""" + +from __future__ import annotations + +import datetime as dt +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from httpx import ASGITransport, AsyncClient + +from backend.server.db import init_db +from tests.fixtures.users_db import point_users_db_at + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def users_db(tmp_path) -> Path: + db = tmp_path / "users.db" + init_db(db) + return db + + +@pytest.fixture(autouse=True) +def _stores_at_tmp(users_db: Path, monkeypatch: pytest.MonkeyPatch): + point_users_db_at(monkeypatch, users_db) + + +from backend.server.db.availability import store as availability_db # noqa: E402 +from backend.server.db.raid_planning import store as planning_db # noqa: E402 + +_WORLD = "Varsoon" +_GUILD = "Exordium" + + +# --------------------------------------------------------------------------- +# DB layer — roles +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_set_and_get_roles_roundtrip(): + await planning_db.set_role(_WORLD, _GUILD, "Tanky", "raider", updated_by="u1") + await planning_db.set_role(_WORLD, _GUILD, "Altchar", "raid_alt", updated_by="u1") + roles = await planning_db.get_roles(_WORLD, _GUILD) + assert {(r["character_name"], r["role"]) for r in roles} == {("Tanky", "raider"), ("Altchar", "raid_alt")} + + +@pytest.mark.asyncio +async def test_role_upsert_replaces(): + await planning_db.set_role(_WORLD, _GUILD, "Tanky", "raider", updated_by="u1") + await planning_db.set_role(_WORLD, _GUILD, "Tanky", "raid_alt", updated_by="u2") + roles = await planning_db.get_roles(_WORLD, _GUILD) + assert roles[0]["role"] == "raid_alt" + + +@pytest.mark.asyncio +async def test_clearing_role_removes_placements_everywhere(): + await planning_db.set_role(_WORLD, _GUILD, "Tanky", "raider", updated_by="u1") + await planning_db.replace_placements( + _WORLD, _GUILD, 0, [{"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": False}], updated_by="u1" + ) + await planning_db.replace_placements( + _WORLD, _GUILD, 1, [{"character_name": "Tanky", "group_num": 2, "slot": 3, "sitout": False}], updated_by="u1" + ) + await planning_db.set_role(_WORLD, _GUILD, "Tanky", None, updated_by="u1") + assert await planning_db.get_roles(_WORLD, _GUILD) == [] + assert await planning_db.get_placements(_WORLD, _GUILD, 0) == [] + assert await planning_db.get_placements(_WORLD, _GUILD, 1) == [] + + +@pytest.mark.asyncio +async def test_invalid_role_raises(): + with pytest.raises(ValueError): + await planning_db.set_role(_WORLD, _GUILD, "Tanky", "bench_warmer", updated_by="u1") + + +# --------------------------------------------------------------------------- +# DB layer — placements +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_replace_placements_is_full_replace_and_team_scoped(): + p1 = [{"character_name": "A", "group_num": 1, "slot": 0, "sitout": False}] + p2 = [{"character_name": "B", "group_num": None, "slot": None, "sitout": True}] + await planning_db.replace_placements(_WORLD, _GUILD, 0, p1, updated_by="u1") + await planning_db.replace_placements(_WORLD, _GUILD, 1, p2, updated_by="u1") + # Replacing team 0 doesn't touch team 1. + await planning_db.replace_placements(_WORLD, _GUILD, 0, [], updated_by="u1") + assert await planning_db.get_placements(_WORLD, _GUILD, 0) == [] + got = await planning_db.get_placements(_WORLD, _GUILD, 1) + assert got[0]["character_name"] == "B" and got[0]["sitout"] == 1 + + +@pytest.mark.asyncio +async def test_prune_placements_beyond_drops_removed_teams(): + for ti in (0, 1, 2): + await planning_db.replace_placements( + _WORLD, _GUILD, ti, [{"character_name": f"C{ti}", "group_num": 1, "slot": 0, "sitout": False}], "u1" + ) + await planning_db.prune_placements_beyond(_WORLD, _GUILD, 1) # only team 0 remains + assert await planning_db.get_placements(_WORLD, _GUILD, 0) != [] + assert await planning_db.get_placements(_WORLD, _GUILD, 1) == [] + assert await planning_db.get_placements(_WORLD, _GUILD, 2) == [] + + +# --------------------------------------------------------------------------- +# DB layer — availability +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_availability_roundtrip_and_default_available(): + await availability_db.set_days("u1", {"2026-08-01": "afk", "2026-08-02": "tentative"}) + days = await availability_db.get_range("u1", "2026-08-01", "2026-08-31") + assert days == {"2026-08-01": "afk", "2026-08-02": "tentative"} + # setting back to available deletes the row + await availability_db.set_days("u1", {"2026-08-01": "available"}) + days = await availability_db.get_range("u1", "2026-08-01", "2026-08-31") + assert days == {"2026-08-02": "tentative"} + + +@pytest.mark.asyncio +async def test_statuses_for_day_across_users(): + await availability_db.set_days("u1", {"2026-08-01": "afk"}) + await availability_db.set_days("u2", {"2026-08-01": "tentative"}) + got = await availability_db.statuses_for_day("2026-08-01") + assert got == {"u1": "afk", "u2": "tentative"} + + +# --------------------------------------------------------------------------- +# API — auth/session plumbing (mirrors test_raid_schedule.py) +# --------------------------------------------------------------------------- + + +def _cookies(user: dict) -> dict: + import os + + from itsdangerous import TimestampSigner + + secret = os.environ.get("SESSION_SECRET", "test-secret") + import base64 + import json as _json + + payload = base64.b64encode(_json.dumps({"user": user}).encode()) + signed = TimestampSigner(secret).sign(payload) + return {"session": signed.decode()} + + +_USER = {"id": "member-1", "username": "member"} + + +def _mock_roster(): + mk = lambda n, c, lv: SimpleNamespace(name=n, cls=c, level=lv, rank_id=5) # noqa: E731 + return [ + mk("Tanky", "Guardian", 70), + mk("Healy", "Templar", 70), + mk("Bardy", "Troubador", 70), + mk("Chanty", "Illusionist", 70), + mk("Alty", "Wizard", 65), + ] + + +def _planner_patches(*, officer: bool, member: bool = True): + roster = _mock_roster() + rank_map = {m.name.lower(): 1 for m in roster} + member_claims = {"approved": [{"character_name": "Tanky"}] if member else [], "pending": None} + return ( + patch("backend.server.api.raid_planning._roster_rank_map", new=AsyncMock(return_value=rank_map)), + patch("backend.server.api.raid_planning.get_active_claims", new=AsyncMock(return_value=member_claims)), + patch( + "backend.server.api.raid_planning._officer_chars", + new=AsyncMock(return_value={"tanky"} if officer else set()), + ), + patch("backend.server.api.raid_planning._guild_roster", new=AsyncMock(return_value=roster)), + patch( + "backend.server.api.raid_planning.raid_schedule_db.get_schedule", + new=AsyncMock(return_value=[{"id": 1, "name": "Team 1", "raids": []}]), + ), + ) + + +async def _get(app, path, user=_USER): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + return await c.get(path, cookies=_cookies(user)) + + +async def _put(app, path, body, user=_USER): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + return await c.put(path, json=body, cookies=_cookies(user)) + + +# --------------------------------------------------------------------------- +# API — visibility gates +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_planner_requires_session(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.get(f"/api/guild/{_GUILD}/raid-planning/0") + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_planner_403_for_non_member(app): + p = _planner_patches(officer=False, member=False) + with p[0], p[1], p[2], p[3], p[4]: + r = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0") + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_planner_member_gets_payload_not_officer(app): + p = _planner_patches(officer=False) + with p[0], p[1], p[2], p[3], p[4]: + r = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0") + assert r.status_code == 200 + body = r.json() + assert body["is_officer"] is False + assert {e["name"] for e in body["roster"]} == {"Tanky", "Healy", "Bardy", "Chanty", "Alty"} + + +@pytest.mark.asyncio +async def test_role_put_requires_officer(app): + p = _planner_patches(officer=False) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put(app, f"/api/guild/{_GUILD}/raid-planning/roles", {"character_name": "Tanky", "role": "raider"}) + assert r.status_code == 403 + + +@pytest.mark.asyncio +async def test_role_put_canonicalises_and_persists(app): + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put(app, f"/api/guild/{_GUILD}/raid-planning/roles", {"character_name": "tAnKy", "role": "raider"}) + assert r.status_code == 200 + assert r.json()["character_name"] == "Tanky" # canonical casing from roster + roles = await planning_db.get_roles(_WORLD, _GUILD) + assert roles[0]["character_name"] == "Tanky" + + +@pytest.mark.asyncio +async def test_role_put_rejects_non_member_character(app): + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put(app, f"/api/guild/{_GUILD}/raid-planning/roles", {"character_name": "Nobody", "role": "raider"}) + assert r.status_code == 404 + + +# --------------------------------------------------------------------------- +# API — placement validation +# --------------------------------------------------------------------------- + + +async def _seed_raider(name="Tanky"): + await planning_db.set_role(_WORLD, _GUILD, name, "raider", updated_by="u1") + + +@pytest.mark.asyncio +async def test_placements_rejects_unrostered_character(app): + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put( + app, + f"/api/guild/{_GUILD}/raid-planning/0/placements", + {"placements": [{"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": False}]}, + ) + assert r.status_code == 400 + assert "not on the raid roster" in r.json()["detail"] + + +@pytest.mark.asyncio +async def test_placements_rejects_double_booked_slot(app): + await _seed_raider("Tanky") + await _seed_raider("Healy") + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put( + app, + f"/api/guild/{_GUILD}/raid-planning/0/placements", + { + "placements": [ + {"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": False}, + {"character_name": "Healy", "group_num": 1, "slot": 0, "sitout": False}, + ] + }, + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_placements_rejects_group_and_sitout(app): + await _seed_raider("Tanky") + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put( + app, + f"/api/guild/{_GUILD}/raid-planning/0/placements", + {"placements": [{"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": True}]}, + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_placements_out_of_range_rejected(app): + await _seed_raider("Tanky") + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put( + app, + f"/api/guild/{_GUILD}/raid-planning/0/placements", + {"placements": [{"character_name": "Tanky", "group_num": 5, "slot": 0, "sitout": False}]}, + ) + assert r.status_code == 400 + + +@pytest.mark.asyncio +async def test_placements_happy_path_roundtrip(app): + await _seed_raider("Tanky") + await _seed_raider("Healy") + p = _planner_patches(officer=True) + with p[0], p[1], p[2], p[3], p[4]: + r = await _put( + app, + f"/api/guild/{_GUILD}/raid-planning/0/placements", + { + "placements": [ + {"character_name": "Tanky", "group_num": 1, "slot": 0, "sitout": False}, + {"character_name": "Healy", "group_num": None, "slot": None, "sitout": True}, + ] + }, + ) + assert r.status_code == 200 + g = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0") + got = {p2["character_name"]: p2 for p2 in g.json()["placements"]} + assert got["Tanky"]["group_num"] == 1 + assert got["Healy"]["sitout"] is True + + +# --------------------------------------------------------------------------- +# API — availability overlay in planner payload +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_planner_overlays_availability_for_claimed_raider(app): + from backend.server.db.claims import store as claims_db + + await _seed_raider("Tanky") + # Approve a claim so Tanky maps to player member-1. + from backend.server.db import upsert_user + + await upsert_user(discord_id="member-1", discord_name="Member One", discord_username="m1", avatar=None) + claim = await claims_db.submit_claim("member-1", "Tanky", world=_WORLD) + await claims_db.review_claim(claim["id"], "approved", "admin") + today = dt.date.today().isoformat() + await availability_db.set_days("member-1", {today: "afk"}) + + p = _planner_patches(officer=False) + with p[0], p[1], p[2], p[3], p[4]: + r = await _get(app, f"/api/guild/{_GUILD}/raid-planning/0?date={today}") + body = r.json() + assert body["availability"].get("tanky") == "afk" + assert body["players"].get("tanky") == "Member One" + + +# --------------------------------------------------------------------------- +# API — /me/availability +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_my_availability_requires_session(app): + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + r = await c.get("/api/me/availability") + assert r.status_code == 401 + + +@pytest.mark.asyncio +async def test_my_availability_is_raider_flag(app): + from backend.server.db import upsert_user + from backend.server.db.claims import store as claims_db + + await upsert_user(discord_id="member-1", discord_name="Member One", discord_username="m1", avatar=None) + claim = await claims_db.submit_claim("member-1", "Tanky", world=_WORLD) + await claims_db.review_claim(claim["id"], "approved", "admin") + + r = await _get(app, "/api/me/availability") + assert r.status_code == 200 + assert r.json()["is_raider"] is False + + await _seed_raider("Tanky") + r = await _get(app, "/api/me/availability") + assert r.json()["is_raider"] is True + + +@pytest.mark.asyncio +async def test_put_availability_validates_window_and_status(app): + today = dt.date.today() + ok_day = (today + dt.timedelta(days=5)).isoformat() + too_far = (today + dt.timedelta(days=200)).isoformat() + + r = await _put(app, "/api/me/availability", {"days": {ok_day: "afk"}}) + assert r.status_code == 200 + r = await _put(app, "/api/me/availability", {"days": {too_far: "afk"}}) + assert r.status_code == 400 + r = await _put(app, "/api/me/availability", {"days": {ok_day: "asleep"}}) + assert r.status_code == 400 + r = await _put(app, "/api/me/availability", {"days": {"not-a-date": "afk"}}) + assert r.status_code == 400 + + # round-trip through GET + r = await _get(app, "/api/me/availability") + assert r.json()["days"] == {ok_day: "afk"} + + # back to available removes it + r = await _put(app, "/api/me/availability", {"days": {ok_day: "available"}}) + r = await _get(app, "/api/me/availability") + assert r.json()["days"] == {}