diff --git a/Dockerfile b/Dockerfile
index c70a656..41ea00c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,4 +9,6 @@ RUN pip install --no-cache-dir -r /app/python/requirements.txt
COPY python /app/python
+EXPOSE 8080
+
CMD ["python", "python/WOM.py"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 70be6af..cb2207e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,4 +10,6 @@ services:
- ./python/config.ini:/app/python/config.ini:ro
- ./python/ranks.ini:/app/python/ranks.ini:ro
- ./data:/app/data
+ ports:
+ - "8080:8080"
restart: unless-stopped
diff --git a/python/WOM.py b/python/WOM.py
index 62bea15..33c7982 100644
--- a/python/WOM.py
+++ b/python/WOM.py
@@ -14,6 +14,9 @@
from utils.rank_utils import load_ranks, save_ranks
from utils.log_csv import log_ehb_to_csv
from utils.commands import setup_commands
+import uvicorn
+from web import create_app
+from web.services.bot_state import BotState
class Client(BaseClient):
@@ -52,7 +55,9 @@ def log(message: str):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted_message = f"{timestamp} - {message}"
print(formatted_message) # Print to terminal
-
+ if 'bot_state' in globals() and bot_state is not None:
+ bot_state.log_buffer.append(formatted_message)
+
# Send to GUI if it's running
botgui_module = sys.modules.get('gui') or sys.modules.get('__main__')
if botgui_module and hasattr(botgui_module, 'BotGUI'):
@@ -92,6 +97,11 @@ def get_messageable_channel(channel_id: int) -> Optional[discord.abc.Messageable
silent = config['settings'].getboolean('silent', False)
debug = config['settings'].getboolean('debug', False)
+# Web interface settings
+web_enabled = config['web'].getboolean('enabled', False) if config.has_section('web') else False
+web_host = config['web'].get('host', '0.0.0.0') if config.has_section('web') else '0.0.0.0'
+web_port = int(config['web'].get('port', '8080')) if config.has_section('web') else 8080
+
if api_key:
log("Wise Old Man API key loaded.")
else:
@@ -247,7 +257,8 @@ async def check_for_rank_changes():
save_ranks(ranks_data)
log("Rank check completed successfully!")
-
+ bot_state.last_rank_check = datetime.now()
+
else:
log(f"Failed to fetch group details: {result.unwrap_err()}")
except Exception as e:
@@ -344,6 +355,7 @@ async def refresh_group_data():
@tasks.loop(seconds=check_interval * 48)
async def refresh_group_task():
msg = await refresh_group_data()
+ bot_state.last_group_refresh = datetime.now()
if post_to_discord:
channel = get_messageable_channel(channel_id)
if channel:
@@ -360,7 +372,7 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
# Ensure discord_names is always a list
if not isinstance(discord_names, list):
discord_names = [discord_names] if discord_names else []
- fans_display = " ".join(discord_names) if discord_names else "0 ðŸ˜ðŸ˜ðŸ˜"
+ fans_display = " ".join(discord_names) if discord_names else "0 😭😭😭"
# Only send message if the rank has changed
if new_rank != old_rank:
@@ -368,8 +380,8 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
if channel:
if post_to_discord:
await channel.send(
- f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** '
- f'with **{ehb}** EHB! 🎉\n'
+ f'🎉 Congratulations **{username}** on moving up to the rank of **{new_rank}** '
+ f'with **{ehb}** EHB! 🎉\n'
f'**Fans:** {fans_display}'
)
log(f"Sent rank up message for {username} to channel: {channel}")
@@ -379,6 +391,20 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
log(f"Error sending message: {e}")
+# Shared state for web interface
+bot_state = BotState(
+ wom_client=wom_client,
+ discord_client=discord_client,
+ group_id=group_id,
+ group_passcode=group_passcode,
+ get_rank=get_rank,
+ check_interval=check_interval,
+ post_to_discord=post_to_discord,
+ silent=silent,
+ debug=debug,
+)
+
+
# Initialize Additional Commands
@@ -409,11 +435,24 @@ async def send_rank_up_message(username, new_rank, old_rank, ehb):
async def main():
async with wom_client, contextlib.AsyncExitStack() as stack:
- await discord_client.start(discord_token)
- try:
- await asyncio.Future() # run forever
- except asyncio.CancelledError:
- await discord_client.close()
+ bot_state.list_all_members_and_ranks = list_all_members_and_ranks
+ bot_state.check_for_rank_changes = check_for_rank_changes
+ bot_state.refresh_group_data = refresh_group_data
+ bot_state.log_func = log
+ bot_state.bot_started_at = datetime.now()
+
+ tasks_to_run = [discord_client.start(discord_token)]
+
+ if web_enabled:
+ web_app = create_app(bot_state)
+ uvi_config = uvicorn.Config(
+ web_app, host=web_host, port=web_port, log_level="info"
+ )
+ server = uvicorn.Server(uvi_config)
+ tasks_to_run.append(server.serve())
+ log(f"Web interface starting on http://{web_host}:{web_port}")
+
+ await asyncio.gather(*tasks_to_run)
try:
loop.run_until_complete(main())
@@ -431,4 +470,3 @@ async def main():
except Exception as e:
print(f"Error during final cleanup: {e}")
print("Cleanup complete. Goodbye!")
-
diff --git a/python/requirements.txt b/python/requirements.txt
index 1283074..53f6c1e 100644
--- a/python/requirements.txt
+++ b/python/requirements.txt
@@ -3,4 +3,7 @@ wom.py>=0.1.0
aiohttp>=3.9.1
asyncio>=3.4.3
requests
-pytest
\ No newline at end of file
+pytest
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+jinja2>=3.1.0
\ No newline at end of file
diff --git a/python/web/__init__.py b/python/web/__init__.py
new file mode 100644
index 0000000..266b256
--- /dev/null
+++ b/python/web/__init__.py
@@ -0,0 +1,5 @@
+"""Web interface for WOMupdtr."""
+
+from .app import create_app
+
+__all__ = ["create_app"]
diff --git a/python/web/app.py b/python/web/app.py
new file mode 100644
index 0000000..afc7bca
--- /dev/null
+++ b/python/web/app.py
@@ -0,0 +1,40 @@
+"""FastAPI application factory for the WOMupdtr web interface."""
+
+from __future__ import annotations
+
+import os
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+
+from .services.bot_state import BotState
+
+_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
+
+
+def create_app(state: BotState) -> FastAPI:
+ """Build and return the configured FastAPI application."""
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ app.state.bot_state = state
+ yield
+
+ app = FastAPI(title="WOMupdtr Dashboard", lifespan=lifespan)
+
+ # Static files
+ static_dir = os.path.join(_BASE_DIR, "static")
+ app.mount("/static", StaticFiles(directory=static_dir), name="static")
+
+ # Register routers (imported here to avoid circular imports)
+ from .routers import admin, charts, dashboard, group, players, reports
+
+ app.include_router(dashboard.router)
+ app.include_router(players.router, prefix="/players", tags=["players"])
+ app.include_router(reports.router, prefix="/reports", tags=["reports"])
+ app.include_router(charts.router, prefix="/charts", tags=["charts"])
+ app.include_router(admin.router, prefix="/admin", tags=["admin"])
+ app.include_router(group.router, prefix="/group", tags=["group"])
+
+ return app
diff --git a/python/web/dependencies.py b/python/web/dependencies.py
new file mode 100644
index 0000000..aa2400f
--- /dev/null
+++ b/python/web/dependencies.py
@@ -0,0 +1,10 @@
+"""FastAPI dependency injection helpers."""
+
+from fastapi import Request
+
+from .services.bot_state import BotState
+
+
+def get_bot_state(request: Request) -> BotState:
+ """Retrieve the shared BotState from the FastAPI application."""
+ return request.app.state.bot_state
diff --git a/python/web/routers/__init__.py b/python/web/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/python/web/routers/admin.py b/python/web/routers/admin.py
new file mode 100644
index 0000000..842b9a8
--- /dev/null
+++ b/python/web/routers/admin.py
@@ -0,0 +1,75 @@
+"""Admin router - bot control, logs, config."""
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+
+from ..dependencies import get_bot_state
+from ..services.bot_state import BotState
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def admin_page(request: Request, state: BotState = Depends(get_bot_state)):
+ return templates.TemplateResponse("admin.html", {
+ "request": request,
+ "bot_state": state,
+ })
+
+
+@router.post("/force-check", response_class=HTMLResponse)
+async def force_check(state: BotState = Depends(get_bot_state)):
+ if state.check_for_rank_changes and hasattr(state.check_for_rank_changes, '__call__'):
+ try:
+ await state.check_for_rank_changes()
+ return HTMLResponse('
Rank check triggered successfully.
')
+ except Exception as e:
+ return HTMLResponse(f'Error: {e}
')
+ return HTMLResponse('Rank check function not available.
')
+
+
+@router.post("/refresh-group", response_class=HTMLResponse)
+async def refresh_group(state: BotState = Depends(get_bot_state)):
+ if state.refresh_group_data:
+ try:
+ msg = await state.refresh_group_data()
+ return HTMLResponse(f'{msg}
')
+ except Exception as e:
+ return HTMLResponse(f'Error: {e}
')
+ return HTMLResponse('Refresh function not available.
')
+
+
+@router.get("/logs", response_class=HTMLResponse)
+async def get_logs(request: Request, state: BotState = Depends(get_bot_state)):
+ log_lines = list(state.log_buffer)[-100:] # Last 100 lines
+ return templates.TemplateResponse("partials/log_feed.html", {
+ "request": request,
+ "log_lines": log_lines,
+ })
+
+
+@router.get("/status")
+async def bot_status(state: BotState = Depends(get_bot_state)):
+ return JSONResponse(content={
+ "bot_started_at": state.bot_started_at.isoformat() if state.bot_started_at else None,
+ "last_rank_check": state.last_rank_check.isoformat() if state.last_rank_check else None,
+ "last_group_refresh": state.last_group_refresh.isoformat() if state.last_group_refresh else None,
+ "check_interval": state.check_interval,
+ "silent": state.silent,
+ "debug": state.debug,
+ "post_to_discord": state.post_to_discord,
+ })
+
+
+@router.post("/config", response_class=HTMLResponse)
+async def update_config(request: Request, state: BotState = Depends(get_bot_state)):
+ form = await request.form()
+ state.silent = "silent" in form
+ state.debug = "debug" in form
+ return HTMLResponse('Configuration updated.
')
diff --git a/python/web/routers/charts.py b/python/web/routers/charts.py
new file mode 100644
index 0000000..02ad5b1
--- /dev/null
+++ b/python/web/routers/charts.py
@@ -0,0 +1,42 @@
+"""Charts router - chart pages and JSON data APIs."""
+
+from fastapi import APIRouter, Depends, Request, Query
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+
+from ..services.ranks_service import get_all_players_sorted, get_rank_distribution
+from ..services.csv_service import get_player_ehb_history
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def charts_page(request: Request):
+ players = get_all_players_sorted()
+ return templates.TemplateResponse("chart.html", {
+ "request": request,
+ "players": players,
+ })
+
+
+@router.get("/api/ehb-history")
+async def ehb_history_api(player: str = Query(...)):
+ history = get_player_ehb_history(player)
+ return JSONResponse(content=history)
+
+
+@router.get("/api/rank-distribution")
+async def rank_distribution_api():
+ dist = get_rank_distribution()
+ return JSONResponse(content=dist)
+
+
+@router.get("/api/top-players")
+async def top_players_api(limit: int = Query(15)):
+ players = get_all_players_sorted()[:limit]
+ return JSONResponse(content=players)
diff --git a/python/web/routers/dashboard.py b/python/web/routers/dashboard.py
new file mode 100644
index 0000000..46e4ace
--- /dev/null
+++ b/python/web/routers/dashboard.py
@@ -0,0 +1,32 @@
+"""Dashboard router - main landing page."""
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from ..dependencies import get_bot_state
+from ..services.bot_state import BotState
+from ..services.ranks_service import get_all_players_sorted, get_rank_distribution
+from ..services.csv_service import get_recent_changes
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def dashboard(request: Request, state: BotState = Depends(get_bot_state)):
+ players = get_all_players_sorted()
+ rank_dist = get_rank_distribution()
+ recent = get_recent_changes(limit=20)
+ return templates.TemplateResponse("dashboard.html", {
+ "request": request,
+ "players": players,
+ "rank_distribution": rank_dist,
+ "recent_changes": recent,
+ "bot_state": state,
+ "player_count": len(players),
+ })
diff --git a/python/web/routers/group.py b/python/web/routers/group.py
new file mode 100644
index 0000000..6416fbd
--- /dev/null
+++ b/python/web/routers/group.py
@@ -0,0 +1,45 @@
+"""Group router - group statistics."""
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+
+from ..services.ranks_service import get_all_players_sorted, get_rank_distribution, get_rank_thresholds
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def group_page(request: Request):
+ players = get_all_players_sorted()
+ rank_dist = get_rank_distribution()
+ thresholds = get_rank_thresholds()
+ total_ehb = sum(p["ehb"] for p in players)
+ avg_ehb = total_ehb / len(players) if players else 0
+ return templates.TemplateResponse("group_stats.html", {
+ "request": request,
+ "players": players,
+ "rank_distribution": rank_dist,
+ "total_ehb": total_ehb,
+ "avg_ehb": avg_ehb,
+ "rank_thresholds": thresholds,
+ })
+
+
+@router.get("/api/stats")
+async def group_stats_api():
+ players = get_all_players_sorted()
+ rank_dist = get_rank_distribution()
+ total_ehb = sum(p["ehb"] for p in players)
+ avg_ehb = total_ehb / len(players) if players else 0
+ return JSONResponse(content={
+ "total_players": len(players),
+ "total_ehb": round(total_ehb, 2),
+ "avg_ehb": round(avg_ehb, 2),
+ "rank_distribution": rank_dist,
+ })
diff --git a/python/web/routers/players.py b/python/web/routers/players.py
new file mode 100644
index 0000000..d41c627
--- /dev/null
+++ b/python/web/routers/players.py
@@ -0,0 +1,53 @@
+"""Players router - list, search, detail, history."""
+
+from fastapi import APIRouter, Depends, Request, Query
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+
+from ..dependencies import get_bot_state
+from ..services.bot_state import BotState
+from ..services.ranks_service import get_all_players_sorted, get_player_detail, search_players
+from ..services.csv_service import get_player_ehb_history
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/", response_class=HTMLResponse)
+async def player_list(request: Request):
+ players = get_all_players_sorted()
+ return templates.TemplateResponse("player_list.html", {
+ "request": request,
+ "players": players,
+ "query": "",
+ })
+
+
+@router.get("/search", response_class=HTMLResponse)
+async def player_search(request: Request, q: str = Query("")):
+ players = search_players(q)
+ return templates.TemplateResponse("partials/player_row.html", {
+ "request": request,
+ "players": players,
+ })
+
+
+@router.get("/{username}", response_class=HTMLResponse)
+async def player_detail(request: Request, username: str):
+ player = get_player_detail(username)
+ if not player:
+ return HTMLResponse("Player not found
", status_code=404)
+ return templates.TemplateResponse("player_detail.html", {
+ "request": request,
+ "player": player,
+ })
+
+
+@router.get("/{username}/history")
+async def player_history(username: str):
+ history = get_player_ehb_history(username)
+ return JSONResponse(content=history)
diff --git a/python/web/routers/reports.py b/python/web/routers/reports.py
new file mode 100644
index 0000000..f8763c3
--- /dev/null
+++ b/python/web/routers/reports.py
@@ -0,0 +1,51 @@
+"""Reports router - weekly and yearly reports."""
+
+from fastapi import APIRouter, Depends, Request, Query
+from fastapi.responses import HTMLResponse
+from fastapi.templating import Jinja2Templates
+
+from ..dependencies import get_bot_state
+from ..services.bot_state import BotState
+from ..services.report_service import get_weekly_report, get_yearly_report
+
+import os
+
+router = APIRouter()
+templates = Jinja2Templates(
+ directory=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "templates")
+)
+
+
+@router.get("/weekly", response_class=HTMLResponse)
+async def weekly_report(request: Request, state: BotState = Depends(get_bot_state)):
+ try:
+ messages = await get_weekly_report(state)
+ report_lines = []
+ for msg in messages:
+ report_lines.extend(msg.split("\n"))
+ except Exception as e:
+ report_lines = [f"Error generating weekly report: {e}"]
+ return templates.TemplateResponse("report_weekly.html", {
+ "request": request,
+ "report_lines": report_lines,
+ })
+
+
+@router.get("/yearly", response_class=HTMLResponse)
+async def yearly_report(
+ request: Request,
+ year: int = Query(None),
+ state: BotState = Depends(get_bot_state),
+):
+ try:
+ messages = await get_yearly_report(state, year=year)
+ report_lines = []
+ for msg in messages:
+ report_lines.extend(msg.split("\n"))
+ except Exception as e:
+ report_lines = [f"Error generating yearly report: {e}"]
+ return templates.TemplateResponse("report_yearly.html", {
+ "request": request,
+ "report_lines": report_lines,
+ "year": year,
+ })
diff --git a/python/web/services/__init__.py b/python/web/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/python/web/services/bot_state.py b/python/web/services/bot_state.py
new file mode 100644
index 0000000..10c4a70
--- /dev/null
+++ b/python/web/services/bot_state.py
@@ -0,0 +1,42 @@
+"""Shared state container between the Discord bot and the FastAPI web server."""
+
+from __future__ import annotations
+
+from collections import deque
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Callable, Optional
+
+
+@dataclass
+class BotState:
+ """Mutable state shared between the Discord bot and web interface.
+
+ Created once at startup and passed to both the Discord bot event loop
+ and the FastAPI application via ``app.state``.
+ """
+
+ # Core client references (set during startup)
+ wom_client: Any = None
+ discord_client: Any = None
+ group_id: int = 0
+ group_passcode: str = ""
+
+ # Function references injected from WOM.py
+ get_rank: Optional[Callable] = None
+ list_all_members_and_ranks: Optional[Callable] = None
+ check_for_rank_changes: Optional[Callable] = None
+ refresh_group_data: Optional[Callable] = None
+ log_func: Optional[Callable] = None
+
+ # Runtime config (readable/writable from admin panel)
+ check_interval: int = 300
+ post_to_discord: bool = True
+ silent: bool = False
+ debug: bool = False
+
+ # Runtime telemetry
+ log_buffer: deque = field(default_factory=lambda: deque(maxlen=500))
+ last_rank_check: Optional[datetime] = None
+ last_group_refresh: Optional[datetime] = None
+ bot_started_at: Optional[datetime] = None
diff --git a/python/web/services/csv_service.py b/python/web/services/csv_service.py
new file mode 100644
index 0000000..ea0de80
--- /dev/null
+++ b/python/web/services/csv_service.py
@@ -0,0 +1,90 @@
+"""Service layer for reading EHB CSV history data."""
+
+import csv
+import os
+
+from utils.log_csv import _resolve_csv_path
+
+
+def get_player_ehb_history(username):
+ """Return list of {timestamp, ehb} for a specific player, sorted by time."""
+ resolved_path = _resolve_csv_path("ehb_log.csv")
+ if not os.path.exists(resolved_path):
+ return []
+
+ history = []
+ try:
+ with open(resolved_path, mode="r", newline="", encoding="utf-8") as f:
+ reader = csv.reader(f)
+ for row in reader:
+ if len(row) < 3:
+ continue
+ ts = row[0].strip()
+ name = row[1].strip()
+ try:
+ ehb = float(row[2].strip())
+ except ValueError:
+ continue
+ if name.lower() == username.lower():
+ history.append({"timestamp": ts, "ehb": ehb})
+ except Exception:
+ return []
+
+ history.sort(key=lambda e: e["timestamp"])
+ return history
+
+
+def get_recent_changes(limit=20):
+ """Return the most recent EHB changes across all players."""
+ resolved_path = _resolve_csv_path("ehb_log.csv")
+ if not os.path.exists(resolved_path):
+ return []
+
+ entries = []
+ try:
+ with open(resolved_path, mode="r", newline="", encoding="utf-8") as f:
+ reader = csv.reader(f)
+ for row in reader:
+ if len(row) < 3:
+ continue
+ ts = row[0].strip()
+ name = row[1].strip()
+ try:
+ ehb = float(row[2].strip())
+ except ValueError:
+ continue
+ entries.append({"timestamp": ts, "username": name, "ehb": ehb})
+ except Exception:
+ return []
+
+ entries.sort(key=lambda e: e["timestamp"], reverse=True)
+ return entries[:limit]
+
+
+def get_all_ehb_entries():
+ """Return all CSV entries grouped by player: {username: [{timestamp, ehb}]}."""
+ resolved_path = _resolve_csv_path("ehb_log.csv")
+ if not os.path.exists(resolved_path):
+ return {}
+
+ grouped = {}
+ try:
+ with open(resolved_path, mode="r", newline="", encoding="utf-8") as f:
+ reader = csv.reader(f)
+ for row in reader:
+ if len(row) < 3:
+ continue
+ ts = row[0].strip()
+ name = row[1].strip()
+ try:
+ ehb = float(row[2].strip())
+ except ValueError:
+ continue
+ grouped.setdefault(name, []).append({"timestamp": ts, "ehb": ehb})
+ except Exception:
+ return {}
+
+ for entries in grouped.values():
+ entries.sort(key=lambda e: e["timestamp"])
+
+ return grouped
diff --git a/python/web/services/ranks_service.py b/python/web/services/ranks_service.py
new file mode 100644
index 0000000..d514209
--- /dev/null
+++ b/python/web/services/ranks_service.py
@@ -0,0 +1,79 @@
+"""Service layer wrapping rank_utils for web consumption."""
+
+import configparser
+import os
+from utils.rank_utils import load_ranks, next_rank
+
+_RANKS_INI = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..', 'ranks.ini')
+
+
+def get_all_players_sorted():
+ """Return list of player dicts sorted by EHB descending."""
+ ranks = load_ranks()
+ players = []
+ for username, data in ranks.items():
+ players.append({
+ "username": username,
+ "ehb": data.get("last_ehb", 0),
+ "rank": data.get("rank", "Unknown"),
+ "discord_name": data.get("discord_name", []),
+ })
+ players.sort(key=lambda p: p["ehb"], reverse=True)
+ return players
+
+
+def get_player_detail(username):
+ """Return detailed player info or None if not found."""
+ ranks = load_ranks()
+ data = ranks.get(username)
+ if not data:
+ # Try case-insensitive match
+ for key, val in ranks.items():
+ if key.lower() == username.lower():
+ username = key
+ data = val
+ break
+ if not data:
+ return None
+ return {
+ "username": username,
+ "ehb": data.get("last_ehb", 0),
+ "rank": data.get("rank", "Unknown"),
+ "discord_name": data.get("discord_name", []),
+ "next_rank": next_rank(username),
+ }
+
+
+def get_rank_distribution():
+ """Return dict of rank_name -> count."""
+ ranks = load_ranks()
+ dist = {}
+ for data in ranks.values():
+ rank = data.get("rank", "Unknown")
+ dist[rank] = dist.get(rank, 0) + 1
+ return dist
+
+
+def search_players(query):
+ """Case-insensitive search by username prefix/substring."""
+ if not query:
+ return get_all_players_sorted()
+ query_lower = query.lower()
+ all_players = get_all_players_sorted()
+ return [p for p in all_players if query_lower in p["username"].lower()]
+
+
+def get_rank_thresholds():
+ """Read ranks.ini and return ordered list of (lower_bound, upper_bound_or_none, rank_name)."""
+ config = configparser.ConfigParser()
+ config.read(_RANKS_INI)
+ thresholds = []
+ for range_key, rank_name in config['Group Ranking'].items():
+ if '+' in range_key:
+ lower = int(range_key.replace('+', ''))
+ thresholds.append({"lower": lower, "upper": None, "name": rank_name})
+ else:
+ lower, upper = map(int, range_key.split('-'))
+ thresholds.append({"lower": lower, "upper": upper, "name": rank_name})
+ thresholds.sort(key=lambda t: t["lower"])
+ return thresholds
diff --git a/python/web/services/report_service.py b/python/web/services/report_service.py
new file mode 100644
index 0000000..a63f18b
--- /dev/null
+++ b/python/web/services/report_service.py
@@ -0,0 +1,51 @@
+"""Service layer for generating reports via the web interface."""
+
+from datetime import datetime, timezone
+
+from weeklyupdater import (
+ generate_weekly_report_messages,
+ generate_yearly_report_messages,
+ most_recent_week_end,
+ most_recent_year_end,
+)
+
+
+async def get_weekly_report(bot_state):
+ """Generate the most recent weekly report as a list of text chunks."""
+ now = datetime.now(timezone.utc)
+ end_date = most_recent_week_end(now)
+
+ def log(msg):
+ if bot_state.log_func:
+ bot_state.log_func(msg)
+
+ messages = await generate_weekly_report_messages(
+ wom_client=bot_state.wom_client,
+ group_id=bot_state.group_id,
+ end_date=end_date,
+ log=log,
+ )
+ return messages
+
+
+async def get_yearly_report(bot_state, year=None):
+ """Generate a yearly report. If year is None, uses the most recent completed year."""
+ now = datetime.now(timezone.utc)
+
+ if year is not None:
+ from weeklyupdater.yearly_reporter import _year_boundary_1200_utc
+ end_date = _year_boundary_1200_utc(year + 1)
+ else:
+ end_date = most_recent_year_end(now)
+
+ def log(msg):
+ if bot_state.log_func:
+ bot_state.log_func(msg)
+
+ messages = await generate_yearly_report_messages(
+ wom_client=bot_state.wom_client,
+ group_id=bot_state.group_id,
+ end_date=end_date,
+ log=log,
+ )
+ return messages
diff --git a/python/web/static/css/style.css b/python/web/static/css/style.css
new file mode 100644
index 0000000..879c2b2
--- /dev/null
+++ b/python/web/static/css/style.css
@@ -0,0 +1,67 @@
+/* Rank badge base */
+.rank-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-weight: bold;
+ font-size: 0.85em;
+ color: #fff;
+}
+
+/* Rank colors - OSRS gem tiers */
+.rank-goblin { background-color: #2d5016; color: #fff; }
+.rank-opal { background-color: #a8c8e0; color: #1a1a2e; }
+.rank-sapphire { background-color: #0f52ba; color: #fff; }
+.rank-emerald { background-color: #50c878; color: #1a1a2e; }
+.rank-red-topaz { background-color: #ff6347; color: #fff; }
+.rank-ruby { background-color: #e0115f; color: #fff; }
+.rank-diamond { background-color: #b9f2ff; color: #1a1a2e; }
+.rank-dragonstone { background-color: #7851a9; color: #fff; }
+.rank-onyx { background-color: #353839; color: #fff; border: 1px solid #666; }
+.rank-zenyte { background-color: #ffd700; color: #1a1a2e; }
+
+/* Stat cards */
+.stat-card {
+ text-align: center;
+ padding: 1.5rem;
+}
+
+.stat-value {
+ font-size: 1.8rem;
+ font-weight: bold;
+ margin: 0.5rem 0 0 0;
+}
+
+/* Log viewer */
+.log-feed {
+ background: #1a1a2e;
+ color: #0f0;
+ font-family: monospace;
+ padding: 1rem;
+ max-height: 400px;
+ overflow-y: auto;
+ border-radius: 8px;
+}
+
+.log-line {
+ padding: 2px 0;
+ font-size: 0.85em;
+}
+
+.log-empty {
+ color: #666;
+ font-style: italic;
+}
+
+/* Navigation active state */
+nav a[aria-current="page"],
+nav a.active {
+ color: var(--pico-primary);
+ font-weight: bold;
+}
+
+/* Report pre-formatted text */
+article pre {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
diff --git a/python/web/static/js/app.js b/python/web/static/js/app.js
new file mode 100644
index 0000000..9b8070a
--- /dev/null
+++ b/python/web/static/js/app.js
@@ -0,0 +1,116 @@
+// OSRS-themed colors for rank distribution
+const RANK_COLORS = {
+ 'goblin': '#2d5016',
+ 'Opal': '#a8c8e0',
+ 'Sapphire': '#0f52ba',
+ 'Emerald': '#50c878',
+ 'Red Topaz': '#ff6347',
+ 'Ruby': '#e0115f',
+ 'Diamond': '#b9f2ff',
+ 'Dragonstone': '#7851a9',
+ 'Onyx': '#353839',
+ 'Zenyte': '#ffd700',
+};
+
+async function loadRankDistribution() {
+ const canvas = document.getElementById('rankDistChart');
+ if (!canvas) return;
+ const resp = await fetch('/charts/api/rank-distribution');
+ const data = await resp.json();
+ const labels = Object.keys(data);
+ const values = Object.values(data);
+ const colors = labels.map(l => RANK_COLORS[l] || '#888');
+ new Chart(canvas, {
+ type: 'doughnut',
+ data: {
+ labels: labels,
+ datasets: [{
+ data: values,
+ backgroundColor: colors,
+ borderWidth: 1,
+ borderColor: '#333',
+ }]
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { position: 'right', labels: { color: '#ccc' } },
+ title: { display: true, text: 'Rank Distribution', color: '#ccc' }
+ }
+ }
+ });
+}
+
+async function loadTopPlayers(limit = 15) {
+ const canvas = document.getElementById('topPlayersChart');
+ if (!canvas) return;
+ const resp = await fetch(`/charts/api/top-players?limit=${limit}`);
+ const data = await resp.json();
+ new Chart(canvas, {
+ type: 'bar',
+ data: {
+ labels: data.map(p => p.username),
+ datasets: [{
+ label: 'EHB',
+ data: data.map(p => p.ehb),
+ backgroundColor: data.map(p => RANK_COLORS[p.rank] || '#4CAF50'),
+ borderWidth: 1,
+ borderColor: '#333',
+ }]
+ },
+ options: {
+ responsive: true,
+ indexAxis: 'y',
+ plugins: {
+ legend: { display: false },
+ title: { display: true, text: 'Top Players by EHB', color: '#ccc' }
+ },
+ scales: {
+ x: { ticks: { color: '#ccc' }, grid: { color: '#333' } },
+ y: { ticks: { color: '#ccc' }, grid: { color: '#333' } }
+ }
+ }
+ });
+}
+
+async function loadPlayerChart(username) {
+ const canvas = document.getElementById('ehbChart');
+ if (!canvas) return;
+ const resp = await fetch(`/charts/api/ehb-history?player=${encodeURIComponent(username)}`);
+ const data = await resp.json();
+ if (!data.length) {
+ canvas.parentElement.innerHTML = 'No EHB history data available for this player.
';
+ return;
+ }
+ new Chart(canvas, {
+ type: 'line',
+ data: {
+ labels: data.map(e => e.timestamp),
+ datasets: [{
+ label: 'EHB',
+ data: data.map(e => e.ehb),
+ borderColor: '#4CAF50',
+ backgroundColor: 'rgba(76, 175, 80, 0.1)',
+ fill: true,
+ tension: 0.3,
+ }]
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ legend: { display: false },
+ title: { display: true, text: `${username} - EHB History`, color: '#ccc' }
+ },
+ scales: {
+ x: { ticks: { color: '#ccc', maxTicksLimit: 10 }, grid: { color: '#333' } },
+ y: { ticks: { color: '#ccc' }, grid: { color: '#333' } }
+ }
+ }
+ });
+}
+
+// Auto-initialize charts on page load
+document.addEventListener('DOMContentLoaded', () => {
+ loadRankDistribution();
+ loadTopPlayers();
+});
diff --git a/python/web/templates/admin.html b/python/web/templates/admin.html
new file mode 100644
index 0000000..ca4d8d1
--- /dev/null
+++ b/python/web/templates/admin.html
@@ -0,0 +1,101 @@
+{% extends "base.html" %}
+
+{% block title %}Admin - WOMupdtr{% endblock %}
+
+{% block content %}
+Admin Panel
+
+
+ Bot Status
+
+
+ Started At:
+ {{ bot_state.bot_started_at if bot_state.bot_started_at else "Not started" }}
+
+
+ Uptime:
+
+ N/A
+
+
+
+ Last Rank Check:
+ {{ bot_state.last_rank_check if bot_state.last_rank_check else "Never" }}
+
+
+ Last Group Refresh:
+ {{ bot_state.last_group_refresh if bot_state.last_group_refresh else "Never" }}
+
+
+ Check Interval:
+ {{ bot_state.check_interval if bot_state.check_interval else "N/A" }} seconds
+
+
+
+
+
+ Actions
+
+
+
+
+
+
+
+
+ Configuration
+
+
+
+
+
+
+
+ Log Viewer
+
+
+
+
+{% endblock %}
diff --git a/python/web/templates/base.html b/python/web/templates/base.html
new file mode 100644
index 0000000..77d840a
--- /dev/null
+++ b/python/web/templates/base.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+ {% block title %}WOMupdtr{% endblock %}
+
+
+
+
+
+
+
+
+ {% block content %}{% endblock %}
+
+
+
+
+
+
+
+
diff --git a/python/web/templates/chart.html b/python/web/templates/chart.html
new file mode 100644
index 0000000..46241d7
--- /dev/null
+++ b/python/web/templates/chart.html
@@ -0,0 +1,77 @@
+{% extends "base.html" %}
+
+{% block title %}Charts - WOMupdtr{% endblock %}
+
+{% block content %}
+Charts
+
+
+
+ Rank Distribution
+
+
+
+ Top Players by EHB
+
+
+
+
+
+ Individual Player EHB History
+
+
+
+
+
+{% endblock %}
diff --git a/python/web/templates/dashboard.html b/python/web/templates/dashboard.html
new file mode 100644
index 0000000..5b7d945
--- /dev/null
+++ b/python/web/templates/dashboard.html
@@ -0,0 +1,117 @@
+{% extends "base.html" %}
+
+{% block title %}Dashboard - WOMupdtr{% endblock %}
+
+{% block content %}
+Dashboard
+
+
+
+ Total Players
+ {{ player_count }}
+
+
+ Total EHB
+ {{ "%.2f"|format(players|sum(attribute='ehb')) }}
+
+
+ Average EHB
+ {{ "%.2f"|format(players|sum(attribute='ehb') / player_count) if player_count > 0 else "0.00" }}
+
+
+ Bot Uptime
+
+ {% if bot_state and bot_state.bot_started_at %}
+ Loading...
+ {% else %}
+ N/A
+ {% endif %}
+
+
+
+
+
+
+ Rank Distribution
+ {% if rank_distribution %}
+
+
+ {% for rank, count in rank_distribution.items() %}
+
+ | {{ rank }} |
+ {{ count }} players |
+
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+ No rank data available.
+ {% endif %}
+
+
+
+ Recent Activity
+ {% if recent_changes %}
+
+
+
+ | Time |
+ Player |
+ EHB |
+
+
+
+ {% for change in recent_changes[:10] %}
+
+ | {{ change.timestamp }} |
+ {{ change.username }} |
+ {{ "%.2f"|format(change.ehb) }} |
+
+ {% endfor %}
+
+
+ {% else %}
+ No recent activity.
+ {% endif %}
+
+
+
+
+ Bot Status
+
+
+ Last Rank Check:
+ {{ bot_state.last_rank_check if bot_state and bot_state.last_rank_check else "Never" }}
+
+
+ Check Interval:
+ {{ bot_state.check_interval if bot_state and bot_state.check_interval else "N/A" }} seconds
+
+
+ Last Group Refresh:
+ {{ bot_state.last_group_refresh if bot_state and bot_state.last_group_refresh else "Never" }}
+
+
+
+
+
+{% endblock %}
diff --git a/python/web/templates/group_stats.html b/python/web/templates/group_stats.html
new file mode 100644
index 0000000..0d9293b
--- /dev/null
+++ b/python/web/templates/group_stats.html
@@ -0,0 +1,44 @@
+{% extends "base.html" %}
+
+{% block title %}Group Stats - WOMupdtr{% endblock %}
+
+{% block content %}
+Group Stats
+
+
+
+ Total Players
+ {{ players|length }}
+
+
+ Total EHB
+ {{ "%.2f"|format(total_ehb) }}
+
+
+ Average EHB
+ {{ "%.2f"|format(avg_ehb) }}
+
+
+
+
+ Rank Distribution
+
+
+
+ | Rank |
+ EHB Range |
+ Players |
+
+
+
+ {% for threshold in rank_thresholds %}
+
+ | {{ threshold.name }} |
+ {{ "%.1f"|format(threshold.lower) }} - {{ "%.1f"|format(threshold.upper) if threshold.upper else "+" }} |
+ {{ rank_distribution.get(threshold.name, 0) if rank_distribution else 0 }} |
+
+ {% endfor %}
+
+
+
+{% endblock %}
diff --git a/python/web/templates/partials/log_feed.html b/python/web/templates/partials/log_feed.html
new file mode 100644
index 0000000..aee4242
--- /dev/null
+++ b/python/web/templates/partials/log_feed.html
@@ -0,0 +1,6 @@
+{% for line in log_lines %}
+{{ line }}
+{% endfor %}
+{% if not log_lines %}
+No log entries yet.
+{% endif %}
diff --git a/python/web/templates/partials/player_row.html b/python/web/templates/partials/player_row.html
new file mode 100644
index 0000000..a9635d3
--- /dev/null
+++ b/python/web/templates/partials/player_row.html
@@ -0,0 +1,9 @@
+{% for player in players %}
+
+ | {{ loop.index }} |
+ {{ player.username }} |
+ {{ player.rank }} |
+ {{ "%.2f"|format(player.ehb) }} |
+ {{ player.discord_name|join(", ") if player.discord_name else "None" }} |
+
+{% endfor %}
diff --git a/python/web/templates/partials/rank_table.html b/python/web/templates/partials/rank_table.html
new file mode 100644
index 0000000..a9635d3
--- /dev/null
+++ b/python/web/templates/partials/rank_table.html
@@ -0,0 +1,9 @@
+{% for player in players %}
+
+ | {{ loop.index }} |
+ {{ player.username }} |
+ {{ player.rank }} |
+ {{ "%.2f"|format(player.ehb) }} |
+ {{ player.discord_name|join(", ") if player.discord_name else "None" }} |
+
+{% endfor %}
diff --git a/python/web/templates/player_detail.html b/python/web/templates/player_detail.html
new file mode 100644
index 0000000..a93c7ae
--- /dev/null
+++ b/python/web/templates/player_detail.html
@@ -0,0 +1,46 @@
+{% extends "base.html" %}
+
+{% block title %}{{ player.username }} - WOMupdtr{% endblock %}
+
+{% block content %}
+{{ player.username }}
+
+
+
+ Current EHB
+ {{ "%.2f"|format(player.ehb) }}
+
+
+ Current Rank
+ {{ player.rank }}
+
+
+ Next Rank
+ {{ player.next_rank if player.next_rank else "Max Rank" }}
+
+
+
+
+ Fans
+ {% if player.discord_name %}
+
+ {% for name in player.discord_name %}
+ - {{ name }}
+ {% endfor %}
+
+ {% else %}
+ No fans yet.
+ {% endif %}
+
+
+
+ EHB History
+
+
+
+
+{% endblock %}
diff --git a/python/web/templates/player_list.html b/python/web/templates/player_list.html
new file mode 100644
index 0000000..337638c
--- /dev/null
+++ b/python/web/templates/player_list.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+
+{% block title %}Players - WOMupdtr{% endblock %}
+
+{% block content %}
+Players
+
+
+
+
+
+
+ | # |
+ Player |
+ Rank |
+ EHB |
+ Fans |
+
+
+
+ {% for player in players %}
+
+ | {{ loop.index }} |
+ {{ player.username }} |
+ {{ player.rank }} |
+ {{ "%.2f"|format(player.ehb) }} |
+ {{ player.discord_name|join(", ") if player.discord_name else "None" }} |
+
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/python/web/templates/report_weekly.html b/python/web/templates/report_weekly.html
new file mode 100644
index 0000000..61693c5
--- /dev/null
+++ b/python/web/templates/report_weekly.html
@@ -0,0 +1,18 @@
+{% extends "base.html" %}
+
+{% block title %}Weekly Report - WOMupdtr{% endblock %}
+
+{% block content %}
+Weekly Report
+
+Generate Fresh Report
+
+
+ {% if report_lines %}
+ {% for line in report_lines %}{{ line }}
+{% endfor %}
+ {% else %}
+ No weekly report data available.
+ {% endif %}
+
+{% endblock %}
diff --git a/python/web/templates/report_yearly.html b/python/web/templates/report_yearly.html
new file mode 100644
index 0000000..2539043
--- /dev/null
+++ b/python/web/templates/report_yearly.html
@@ -0,0 +1,23 @@
+{% extends "base.html" %}
+
+{% block title %}Yearly Report - WOMupdtr{% endblock %}
+
+{% block content %}
+Yearly Report{% if year %} - {{ year }}{% endif %}
+
+
+
+
+ {% if report_lines %}
+ {% for line in report_lines %}{{ line }}
+{% endfor %}
+ {% else %}
+ No yearly report data available.
+ {% endif %}
+
+{% endblock %}