diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..35c3cd8 --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# Copy to .env and fill in. Never commit .env. + +# --- Airtable --- +# Personal Access Token (read-only scope on the base below). +# Generate at https://airtable.com/create/tokens — scopes: data.records:read on base appoByydxIQANKtSh, tables Resource + Booking Resource. +AIRTABLE_PAT= +AIRTABLE_BASE_ID=appoByydxIQANKtSh + +# --- Session / auth --- +# Random 32+ bytes. Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))" +SESSION_SECRET= + +# Local admin (used while AUTH_MODE=local). +# Generate hash with: python -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password-here'))" +# IMPORTANT: wrap the hash in SINGLE QUOTES — bcrypt hashes contain $ characters +# that docker-compose will otherwise interpret as variable substitutions. +ADMIN_USERNAME=admin +ADMIN_PASSWORD_BCRYPT='' + +# AUTH_MODE: local (v1) or azure (v2 — not yet implemented). +AUTH_MODE=local + +# Skip auth entirely in local dev. NEVER set true in production. +DEV_AUTH_BYPASS=false + +# --- Server --- +# Host port to bind the container to. Auto-picked by deploy.sh from 8200-8299 and persisted here. +UTILISATION_DEPT_PORT=8200 + +# Optional Sentry-style overrides for cache TTLs (seconds). +CACHE_TTL_RESOURCES=600 +CACHE_TTL_BOOKINGS=60 +CACHE_TTL_META=600 + +# --- Azure AD (unused in v1, present for v2 swap) --- +AZURE_TENANT_ID= +AZURE_CLIENT_ID= diff --git a/.gitignore b/.gitignore index b24d71e..27167d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,50 +1,39 @@ -# These are some examples of commonly ignored file patterns. -# You should customize this list as applicable to your project. -# Learn more about .gitignore: -# https://www.atlassian.com/git/tutorials/saving-changes/gitignore +# env / secrets +.env +.env.* +!.env.example -# Node artifact files -node_modules/ -dist/ +# deploy artefacts +.deployed +deploy/apache-utilisation-dept.conf -# Compiled Java class files -*.class - -# Compiled Python bytecode +# python +__pycache__/ *.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ -# Log files +# node / vite +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/**/*.tsbuildinfo +frontend/.tsbuild-node/ + +# logs *.log +backend/auth.log +backend/auth.log.* -# Package files -*.jar - -# Maven -target/ -dist/ - -# JetBrains IDE -.idea/ - -# Unit test reports -TEST*.xml - -# Generated by MacOS +# os .DS_Store - -# Generated by Windows Thumbs.db -# Applications -*.app -*.exe -*.war - -# Large media files -*.mp4 -*.tiff -*.avi -*.flv -*.mov -*.wmv - +# editor +.vscode/ +.idea/ +*.swp diff --git a/README.md b/README.md new file mode 100644 index 0000000..a1813a4 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# utilisation-dept + +L'Oréal Utilisation Dashboard — internal tool that merges Zoho time-log +exports with Airtable resource/booking data and renders utilisation +charts per department, per person, and per project. + +This is a clean rewrite of an earlier static SPA that shipped an +Airtable Personal Access Token in the JS bundle. The new architecture +keeps all secrets on the backend. + +## Architecture + +``` +Browser ─► Apache (optical-dev.oliver.solutions, shared vhost) + ├─ /utilisation-dept/ → static SPA (Vite build in /var/www/html/utilisation-dept/) + └─ /utilisation-dept/api/ → FastAPI container (127.0.0.1:) + │ + └─ Airtable REST API (PAT held in .env) +``` + +- **Backend**: FastAPI + uvicorn in a Docker container, bound to + 127.0.0.1 only. Apache fronts the public traffic. +- **Frontend**: React + Vite + TypeScript + Tailwind + Recharts. Vite + `base: '/utilisation-dept/'`. Built into `/var/www/html/utilisation-dept/`. +- **Database**: none. Airtable is the source of truth; uploaded Zoho + files are parsed in memory and discarded. +- **Auth**: local admin account today (bcrypt creds in `.env`). + Designed for an Azure AD/MSAL swap-in later — see `app/auth/azure.py`. + +## First-time setup + +### 1. Rotate the Airtable PAT (do this before anything else) + +The old SPA had its PAT hardcoded in the JS bundle. Assume that token is +compromised. + +1. Go to . +2. Revoke the old token (`patHAB...`) if it still exists. +3. Create a new Personal Access Token: + - **Name**: `utilisation-dept-backend` + - **Scopes**: `data.records:read` + - **Access**: limit to base `appoByydxIQANKtSh` only, tables + `Resource` and `Booking Resource`. +4. Copy the token. You'll paste it into `.env` below. + +### 2. Generate secrets + +```bash +# Session secret (paste output into SESSION_SECRET): +python3 -c "import secrets; print(secrets.token_urlsafe(48))" + +# Admin password hash (replace 'your-password' with the real password): +python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('your-password'))" +``` + +> **Heads-up**: bcrypt hashes contain `$` characters. Wrap the hash in single +> quotes in `.env`, otherwise docker-compose will treat the `$...` segments as +> variable substitutions and the container will receive a truncated value: +> +> ``` +> ADMIN_PASSWORD_BCRYPT='$2b$12$abc...xyz' +> ``` + +### 3. Configure `.env` + +```bash +cp .env.example .env +# edit .env and fill in: +# AIRTABLE_PAT (from step 1) +# SESSION_SECRET (from step 2) +# ADMIN_PASSWORD_BCRYPT (from step 2) +``` + +## Local development + +```bash +# Backend +cd backend +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8200 + +# Frontend (in another terminal) +cd frontend +npm install +npm run dev +# open http://localhost:5173/utilisation-dept/ +``` + +The Vite dev server proxies `/utilisation-dept/api/*` to +`http://localhost:8200`. + +To skip auth entirely during dev, set `DEV_AUTH_BYPASS=true` in `.env` +(NEVER do this in production — the startup banner will warn loudly). + +## Local Docker + +```bash +docker compose up --build +# health: curl http://localhost:8200/api/health +``` + +## Tests + +```bash +cd backend && pytest +cd frontend && npm run typecheck && npm run lint +``` + +## Deployment to optical-dev.oliver.solutions + +The server hosts many small apps behind a shared Apache vhost. This app +lives at `/opt/utilisation-dept/`. + +### First deploy + +```bash +ssh user@optical-dev.oliver.solutions +sudo git clone /opt/utilisation-dept +cd /opt/utilisation-dept + +# Create .env (see "First-time setup" above for value generation). +sudo cp .env.example .env +sudo vi .env + +./deploy/deploy.sh +``` + +`deploy.sh` will: +- Pick a free port in 8200–8299 and persist it to `.env`. +- Render `deploy/apache-utilisation-dept.conf` from the template. +- Build the frontend → `/var/www/html/utilisation-dept/`. +- Build + start the Docker container. +- Health-poll `/api/health`. +- Print the Apache `Include` line for the shared vhost. + +### Wire into the shared vhost (one time only) + +Add **inside** `` of +`/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf`: + +```apache + Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf +``` + +Then: + +```bash +sudo apachectl configtest && sudo systemctl reload apache2 +``` + +### Smoke-check + +```bash +curl -sI https://optical-dev.oliver.solutions/utilisation-dept/api/health +curl -sf https://optical-dev.oliver.solutions/utilisation-dept/api/health +# expect 200 and {"ok":true,"version":"0.1.0"} +``` + +Then open in a +browser and confirm: + +- Login screen renders. +- View-source on the loaded JS: search for `patHAB` — must return + nothing. +- Logging in with the local admin creds shows the dashboard. + +### Subsequent deploys + +```bash +ssh user@optical-dev.oliver.solutions +cd /opt/utilisation-dept +./deploy/deploy.sh # --no-pull, --no-build, --no-frontend, --logs available +``` + +## Operations + +### Where the auth log lives + +`/opt/utilisation-dept/backend/logs/auth.log` (mounted into the +container at `/app/logs/`). Rotating, 5 MB × 5 backups. + +This is the only audit trail for who logged in when — there's no +database. + +### Rotating the PAT + +1. Generate a new PAT (same scopes as in setup). +2. Edit `.env` → update `AIRTABLE_PAT`. +3. `docker compose -p utilisation-dept restart backend` +4. Revoke the old PAT in the Airtable portal. + +### Forgot admin password + +1. Generate a new bcrypt hash: + `python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('new-password'))"` +2. Edit `.env` → update `ADMIN_PASSWORD_BCRYPT`. +3. `docker compose -p utilisation-dept restart backend` + +## Future work + +- **Azure AD / MSAL SSO**: `app/auth/azure.py` is stubbed. Flip + `AUTH_MODE=azure` in `.env`, fill in `AZURE_TENANT_ID` + + `AZURE_CLIENT_ID`, register the SPA in Azure (platform type: + **Single-page application**), wire MSAL on the frontend, send the + **ID token** to the backend. See `~/.claude/skills/azure-ad-msal-auth.md`. +- **Database** if/when we need history, audit trails, or to cache more + aggressively. Today the data lives in Airtable and the Zoho upload + is ephemeral. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..02651a0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,48 @@ +# utilisation-dept backend +# Decisions: +# - Use python:3.12-slim for small footprint with broad wheel availability. +# - Install tini in the image so PID 1 reaps zombies and forwards signals +# (uvicorn + passlib bcrypt subprocesses behave better under tini). +# - Run as non-root `appuser`. /app/logs is chowned so the mounted volume +# from docker-compose remains writable. +# - No HEALTHCHECK here — docker-compose.yml owns the healthcheck. + +FROM python:3.12-slim AS base + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +# tini for clean PID 1 behaviour; libffi/openssl already in slim base. +RUN apt-get update \ + && apt-get install -y --no-install-recommends tini \ + && rm -rf /var/lib/apt/lists/* + +# Non-root user +RUN groupadd --system appuser \ + && useradd --system --gid appuser --home /app --shell /usr/sbin/nologin appuser + +WORKDIR /app + +# Install deps first for better layer caching. +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +# Copy app code +COPY app /app/app + +# Ensure logs dir exists and is writable by appuser (compose mounts a volume here). +RUN mkdir -p /app/logs && chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8000 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["uvicorn", "app.main:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--proxy-headers", \ + "--forwarded-allow-ips=*"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..6841fc2 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +"""utilisation-dept backend package.""" + +__version__ = "0.1.0" diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/azure.py b/backend/app/auth/azure.py new file mode 100644 index 0000000..94d8254 --- /dev/null +++ b/backend/app/auth/azure.py @@ -0,0 +1,25 @@ +"""Azure AD JWT verification stub. + +Decisions: +- v1 ships with AUTH_MODE=local. This module exists so that switching to + Azure later is a one-line env change — the dependency is wired by import. +- DEV_AUTH_BYPASS still works here so a dev environment can skip Azure + during early frontend work. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException, Request, status + +from app.config import settings + + +def verify_jwt(request: Request) -> dict[str, Any]: + if settings.DEV_AUTH_BYPASS: + return {"username": "dev", "mode": "bypass"} + raise HTTPException( + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail="Azure AD not yet configured", + ) diff --git a/backend/app/auth/local.py b/backend/app/auth/local.py new file mode 100644 index 0000000..834a42d --- /dev/null +++ b/backend/app/auth/local.py @@ -0,0 +1,51 @@ +"""Local auth — bcrypt-verified admin credential plus session-cookie check. + +Decisions: +- passlib's bcrypt backend is used so we don't depend on the (deprecated) + bcrypt package interface details directly. +- DEV_AUTH_BYPASS short-circuits to a synthetic user; main.py logs a + WARNING at startup if it's enabled. +- verify_password and verify_session are both safe to call when + ADMIN_PASSWORD_BCRYPT is empty — they just return False / 401. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException, Request, status +from passlib.context import CryptContext + +from app.auth.session import COOKIE_NAME, verify_signed +from app.config import settings + + +_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +def verify_password(username: str, password: str) -> bool: + if not settings.ADMIN_PASSWORD_BCRYPT: + return False + if username != settings.ADMIN_USERNAME: + return False + try: + return _pwd_context.verify(password, settings.ADMIN_PASSWORD_BCRYPT) + except Exception: + # passlib raises on malformed hashes — treat as auth failure. + return False + + +def verify_session(request: Request) -> dict[str, Any]: + """FastAPI dependency: returns {"username": ..., "mode": ...} or 401.""" + if settings.DEV_AUTH_BYPASS: + return {"username": "dev", "mode": "bypass"} + + cookie = request.cookies.get(COOKIE_NAME) + if not cookie: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + username = verify_signed(cookie) + if not username: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Session expired") + + return {"username": username, "mode": "local"} diff --git a/backend/app/auth/session.py b/backend/app/auth/session.py new file mode 100644 index 0000000..8aa3ff0 --- /dev/null +++ b/backend/app/auth/session.py @@ -0,0 +1,58 @@ +"""Session cookie signing helpers. + +Decisions: +- Uses itsdangerous.TimestampSigner so we can enforce max-age server-side + even if the cookie's own Max-Age is tampered with (defence in depth). +- The cookie value is "" signed; no JWT, no JSON. Sessions don't + need to carry data here — the user is single-tenant admin. +""" + +from __future__ import annotations + +from itsdangerous import BadSignature, SignatureExpired, TimestampSigner +from fastapi import Response + +from app.config import settings + + +def _signer() -> TimestampSigner: + return TimestampSigner(settings.SESSION_SECRET, salt="ud-session-v1") + + +def sign_username(username: str) -> str: + return _signer().sign(username.encode("utf-8")).decode("utf-8") + + +def verify_signed(value: str, max_age: int | None = None) -> str | None: + """Return username if signature valid and not expired; else None.""" + try: + max_age = max_age if max_age is not None else settings.SESSION_MAX_AGE + unsigned = _signer().unsign(value, max_age=max_age) + return unsigned.decode("utf-8") + except SignatureExpired: + return None + except BadSignature: + return None + + +COOKIE_NAME = "ud_session" + + +def set_session_cookie(response: Response, username: str) -> None: + signed = sign_username(username) + response.set_cookie( + key=COOKIE_NAME, + value=signed, + max_age=settings.SESSION_MAX_AGE, + path=settings.cookie_path, + httponly=True, + secure=True, + samesite="lax", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie( + key=COOKIE_NAME, + path=settings.cookie_path, + ) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..2bee64f --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,89 @@ +"""Application settings loaded from environment variables. + +Decisions: +- Uses pydantic-settings v2 so values are validated at import-time. Missing + required fields raise at startup rather than producing a 500 later. +- All cache TTLs default to the values documented in the docker-compose file. +- AIRTABLE_PAT and SESSION_SECRET have empty defaults so that tests / local + dev can boot without them (auth bypass paths short-circuit the checks). +""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=None, + case_sensitive=True, + extra="ignore", + ) + + # Airtable + AIRTABLE_PAT: str = "" + AIRTABLE_BASE_ID: str = "appoByydxIQANKtSh" + # Table names — kept as constants here; could be promoted to env vars later. + AIRTABLE_TABLE_RESOURCES: str = "Resource" + AIRTABLE_TABLE_BOOKINGS: str = "Booking Resource" + + # Session / auth + SESSION_SECRET: str = "dev-insecure-secret-change-me" + ADMIN_USERNAME: str = "admin" + ADMIN_PASSWORD_BCRYPT: str = "" + AUTH_MODE: Literal["local", "azure"] = "local" + DEV_AUTH_BYPASS: bool = False + + # Cache TTLs (seconds) + CACHE_TTL_RESOURCES: int = 600 + CACHE_TTL_BOOKINGS: int = 60 + CACHE_TTL_META: int = 600 + + # Azure (stubbed in v1) + AZURE_TENANT_ID: str = "" + AZURE_CLIENT_ID: str = "" + + # App + APP_BASE_PATH: str = "/utilisation-dept" + # Override cookie path explicitly when needed (tests use "/" to bypass + # the Apache-stripped-prefix mismatch). + SESSION_COOKIE_PATH: str = "" + + # CORS — comma separated origins. Empty in prod (same-origin via Apache). + CORS_ALLOWED_ORIGINS: str = "" + + # Session cookie + SESSION_MAX_AGE: int = 60 * 60 * 8 # 8h + + # Multipart upload limit (bytes) — Zoho exports rarely exceed 20 MB. + MAX_UPLOAD_BYTES: int = 20 * 1024 * 1024 + + @property + def cookie_path(self) -> str: + # Allow an explicit override for tests / unusual deploys. + if self.SESSION_COOKIE_PATH: + return self.SESSION_COOKIE_PATH + # itsdangerous cookie path; needs trailing slash to match the SPA mount. + p = self.APP_BASE_PATH.rstrip("/") + return f"{p}/" if p else "/" + + @property + def cors_origins(self) -> list[str]: + if not self.CORS_ALLOWED_ORIGINS: + # Dev convenience: when bypass is on, allow Vite default. + if self.DEV_AUTH_BYPASS: + return ["http://localhost:5173"] + return [] + return [o.strip() for o in self.CORS_ALLOWED_ORIGINS.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/app/deps/__init__.py b/backend/app/deps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/deps/airtable.py b/backend/app/deps/airtable.py new file mode 100644 index 0000000..29e0b4a --- /dev/null +++ b/backend/app/deps/airtable.py @@ -0,0 +1,46 @@ +"""AirtableClient singleton. + +Decisions: +- One module-level httpx.AsyncClient with sane defaults (HTTP/2 off, + 10s connect / 30s read). Reused across requests for connection pooling. +- The client is closed on app shutdown via main.py's lifespan handler. +""" + +from __future__ import annotations + +import httpx + +from app.config import settings + + +_AIRTABLE_API_BASE = "https://api.airtable.com/v0" + + +class AirtableClient: + def __init__(self) -> None: + self._client: httpx.AsyncClient | None = None + + def _build(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=f"{_AIRTABLE_API_BASE}/{settings.AIRTABLE_BASE_ID}", + headers={ + "Authorization": f"Bearer {settings.AIRTABLE_PAT}", + "Accept": "application/json", + }, + timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0), + ) + + @property + def client(self) -> httpx.AsyncClient: + if self._client is None: + self._client = self._build() + return self._client + + async def aclose(self) -> None: + if self._client is not None: + await self._client.aclose() + self._client = None + + +# Module-level singleton — imported by services/airtable_fetch.py. +airtable_client = AirtableClient() diff --git a/backend/app/deps/auth.py b/backend/app/deps/auth.py new file mode 100644 index 0000000..e97c2bd --- /dev/null +++ b/backend/app/deps/auth.py @@ -0,0 +1,31 @@ +"""Auth dependency selector. + +Picks local or azure verifier at import-time based on AUTH_MODE. + +The spec asks for an import-time selection, but we wrap the chosen +verifier in a small indirection so that DEV_AUTH_BYPASS can be flipped +at runtime (useful in tests) without re-importing the whole app graph. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import Request + +from app.config import settings + + +if settings.AUTH_MODE == "azure": + from app.auth.azure import verify_jwt as _verifier +else: + from app.auth.local import verify_session as _verifier + + +def auth_required(request: Request) -> dict[str, Any]: + # Re-check bypass at each request: settings.DEV_AUTH_BYPASS may have + # been toggled by tests. Production deployments do not touch this. + from app.config import get_settings + if get_settings().DEV_AUTH_BYPASS: + return {"username": "dev", "mode": "bypass"} + return _verifier(request) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..2d2e24c --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,109 @@ +"""FastAPI app entrypoint. + +Decisions: +- All routers already mount under /api/... so we attach them at app root. +- A small Starlette middleware enforces the 20 MB Content-Length cap for + multipart bodies before they hit any route handler. +- CORS is only enabled when configured (or when DEV_AUTH_BYPASS=true, + which adds the Vite dev origin). In prod the SPA is same-origin via Apache. +- slowapi state and exception handler are wired here. +- Lifespan hook closes the shared httpx client cleanly. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +from app import __version__ +from app.config import settings +from app.deps.airtable import airtable_client +from app.routers import airtable as airtable_router +from app.routers import auth as auth_router +from app.routers import health as health_router +from app.routers import timelog as timelog_router +from app.routers import utilisation as utilisation_router + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO, format="%(asctime)sZ %(levelname)s %(name)s: %(message)s") + + +class ContentLengthLimitMiddleware(BaseHTTPMiddleware): + """Reject requests whose Content-Length exceeds settings.MAX_UPLOAD_BYTES.""" + + def __init__(self, app: ASGIApp, max_bytes: int) -> None: + super().__init__(app) + self.max_bytes = max_bytes + + async def dispatch(self, request: Request, call_next): + cl = request.headers.get("content-length") + if cl is not None: + try: + if int(cl) > self.max_bytes: + return JSONResponse({"detail": "Payload too large"}, status_code=413) + except ValueError: + pass + return await call_next(request) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if settings.DEV_AUTH_BYPASS: + logger.warning("DEV_AUTH_BYPASS=true — auth is disabled. DO NOT use in production.") + logger.info("utilisation-dept backend v%s starting (AUTH_MODE=%s)", __version__, settings.AUTH_MODE) + yield + await airtable_client.aclose() + + +def create_app() -> FastAPI: + app = FastAPI( + title="utilisation-dept", + version=__version__, + lifespan=lifespan, + # Docs at /api/docs so Apache routes /utilisation-dept/api/docs there. + docs_url="/api/docs", + redoc_url=None, + openapi_url="/api/openapi.json", + ) + + # CORS — only when we have origins. Same-origin in prod via Apache. + origins = settings.cors_origins + if origins: + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.add_middleware(ContentLengthLimitMiddleware, max_bytes=settings.MAX_UPLOAD_BYTES) + + # slowapi wiring — limiter instance defined in auth router. + app.state.limiter = auth_router.limiter + app.add_middleware(SlowAPIMiddleware) + + @app.exception_handler(RateLimitExceeded) + async def _rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse({"detail": "Too many requests"}, status_code=429) + + # Routers + app.include_router(health_router.router) + app.include_router(auth_router.router) + app.include_router(airtable_router.router) + app.include_router(timelog_router.router) + app.include_router(utilisation_router.router) + + return app + + +app = create_app() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000..00a5488 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,132 @@ +"""Pydantic v2 schemas shared across routers and services. + +Field names are camelCase to match the API contract the frontend agent +will consume directly. Internally services produce dicts in this exact +shape; FastAPI serialises pydantic models the same way so either path is fine. +""" + +from __future__ import annotations + +from datetime import date as DateType, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +# ---------- Auth ---------- + +class LoginRequest(BaseModel): + username: str + password: str + + +class MeResponse(BaseModel): + username: str + mode: str # "local" | "azure" | "bypass" + + +# ---------- Airtable ---------- + +class Resource(BaseModel): + recordId: str + name: str + email: str | None = None + department: str | None = None + roles: list[str] = Field(default_factory=list) + inactive: bool = False + availHoursPerWeek: float = 0.0 + startDate: DateType | None = None + endDate: DateType | None = None + employmentType: str | None = None # "FTE" | "Freelancer" + country: str | None = None + + +class Booking(BaseModel): + id: str + task: str | None = None + startDate: DateType | None = None + endDate: DateType | None = None + resourceName: str | None = None + projectNumber: str | None = None + projectName: str | None = None + department: str | None = None + division: str | None = None + hoursSelection: list[str] = Field(default_factory=list) + totalHoursBooked: float = 0.0 + bookingStatus: str | None = None + placeholder: bool = False + + +class ResourcesResponse(BaseModel): + resources: list[Resource] + cached_at: datetime + + +class BookingsResponse(BaseModel): + bookings: list[Booking] + cached_at: datetime + + +class MetaResponse(BaseModel): + departments: list[str] + billingTypes: list[str] + employmentTypes: list[str] + bookingStatuses: list[str] + + +# ---------- Timelog ---------- + +class TimelogRow(BaseModel): + date: DateType | None = None + employee: str | None = None + project: str | None = None + task: str | None = None + hours: float = 0.0 + billable: bool = False + + +class ParseResponse(BaseModel): + rows: list[TimelogRow] + unrecognised_columns: list[str] + content_hash: str + + +# ---------- Utilisation ---------- + +class UtilisationSummaryRow(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + period: str # "2026-W19" or "2026-05" + employee: str + department: str | None = None + employmentType: str | None = None + availableHours: float = 0.0 + bookedHours: float = 0.0 + loggedHours: float = 0.0 + billableHours: float = 0.0 + nonBillableHours: float = 0.0 + forecastHours: float = 0.0 + actualUtilisationPct: float = 0.0 + bookedUtilisationPct: float = 0.0 + + +class UtilisationFiltersApplied(BaseModel): + from_: str | None = Field(default=None, alias="from") + to: str | None = None + department: str | None = None + name: str | None = None + billing_type: str | None = None + + model_config = ConfigDict(populate_by_name=True) + + +class UtilisationSummaryResponse(BaseModel): + rows: list[UtilisationSummaryRow] + filters_applied: UtilisationFiltersApplied + + +# ---------- Health ---------- + +class HealthResponse(BaseModel): + ok: bool + version: str diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/airtable.py b/backend/app/routers/airtable.py new file mode 100644 index 0000000..a4b59c6 --- /dev/null +++ b/backend/app/routers/airtable.py @@ -0,0 +1,96 @@ +"""Airtable proxy endpoints — cached, protected by auth_required.""" + +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app.config import settings +from app.deps.auth import auth_required +from app.services.airtable_fetch import fetch_bookings, fetch_resources +from app.services.cache import TTLAsyncCache + + +router = APIRouter(prefix="/api/airtable", tags=["airtable"], dependencies=[Depends(auth_required)]) + + +# Module-level caches — TTLs from settings. +_resources_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_RESOURCES, maxsize=4) +_bookings_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_BOOKINGS, maxsize=64) +_meta_cache = TTLAsyncCache(ttl=settings.CACHE_TTL_META, maxsize=2) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +# ---- /resources ---- +@router.get("/resources") +async def get_resources( + include_inactive: bool = Query(False), + refresh: bool = Query(False), +) -> dict[str, Any]: + key = f"resources:{include_inactive}" + if refresh: + _resources_cache.invalidate(key) + + async def loader() -> dict[str, Any]: + rows = await fetch_resources(include_inactive=include_inactive) + return {"resources": rows, "cached_at": _now_iso()} + + return await _resources_cache.get_or_set(key, loader) + + +# ---- /bookings ---- +@router.get("/bookings") +async def get_bookings( + from_: str | None = Query(None, alias="from"), + to: str | None = Query(None), + refresh: bool = Query(False), +) -> dict[str, Any]: + try: + from_d = date.fromisoformat(from_) if from_ else None + to_d = date.fromisoformat(to) if to else None + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid date: {e}") + + key = f"bookings:{from_d}:{to_d}" + if refresh: + _bookings_cache.invalidate(key) + + async def loader() -> dict[str, Any]: + rows = await fetch_bookings(from_=from_d, to=to_d) + return {"bookings": rows, "cached_at": _now_iso()} + + return await _bookings_cache.get_or_set(key, loader) + + +# ---- /meta ---- +@router.get("/meta") +async def get_meta() -> dict[str, Any]: + """Static-ish dropdown values. Currently a constant; would derive from + Airtable schema endpoint in a future iteration.""" + + async def loader() -> dict[str, Any]: + return { + "departments": [ + "Creative Team", + "Opera Upload Team", + "Operation Team", + "Project Management Team", + "Syndication Team", + "Transcreation Team", + "House Admin", + ], + "billingTypes": ["Client Related", "Fee Related", "Idle Time", "Leave Hours"], + "employmentTypes": ["FTE", "Freelancer"], + "bookingStatuses": ["Active", "Soft Booking", "Fully Booked", "Partially Booked"], + } + + return await _meta_cache.get_or_set("meta:v1", loader) + + +# Exported so tests can inspect / reset caches. +__all__ = ["router", "_resources_cache", "_bookings_cache", "_meta_cache"] diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..7e87ee1 --- /dev/null +++ b/backend/app/routers/auth.py @@ -0,0 +1,88 @@ +"""Auth endpoints: login / logout / me. + +Decisions: +- Login is rate-limited 5/min/IP via slowapi keyed on the remote address. + We wire the limiter on this single route, not globally — the rest of the + API is auth-protected anyway. +- Both success and failure attempts are appended to /app/logs/auth.log + via a RotatingFileHandler (5 MB × 5 backups). +- Cookie domain/path/flags handled by app.auth.session. +- We intentionally DO NOT use `from __future__ import annotations` in this + module: slowapi wraps the handler with a decorator that breaks FastAPI's + forward-ref resolution for the `LoginRequest` body parameter under + Python 3.14 / pydantic 2.x. +""" + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response, status +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.auth.local import verify_password +from app.auth.session import clear_session_cookie, set_session_cookie +from app.config import settings +from app.deps.auth import auth_required +from app.models.schemas import LoginRequest, MeResponse + + +# ---- auth.log file logger ---- +auth_logger = logging.getLogger("ud.auth") +if not auth_logger.handlers: + log_dir = Path("/app/logs") + try: + log_dir.mkdir(parents=True, exist_ok=True) + handler = RotatingFileHandler( + log_dir / "auth.log", + maxBytes=5 * 1024 * 1024, + backupCount=5, + encoding="utf-8", + ) + except (PermissionError, OSError): + # Fallback for local dev when /app/logs isn't writable. + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("%(asctime)sZ %(message)s")) + auth_logger.addHandler(handler) + auth_logger.setLevel(logging.INFO) + auth_logger.propagate = False + + +limiter = Limiter(key_func=get_remote_address) + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +def _log_attempt(request: Request, username: str, outcome: str) -> None: + ip = get_remote_address(request) + auth_logger.info("ip=%s user=%s outcome=%s", ip, username, outcome) + + +@router.post("/login") +@limiter.limit("5/minute") +async def login(request: Request, response: Response, body: LoginRequest = Body(...)): + if settings.DEV_AUTH_BYPASS: + _log_attempt(request, body.username, "bypass") + return {"ok": True, "username": "dev", "mode": "bypass"} + + if not verify_password(body.username, body.password): + _log_attempt(request, body.username, "fail") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") + + set_session_cookie(response, body.username) + _log_attempt(request, body.username, "success") + return {"ok": True, "username": body.username, "mode": settings.AUTH_MODE} + + +@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT) +async def logout(response: Response) -> Response: + clear_session_cookie(response) + # Explicit 204 with no body. + response.status_code = status.HTTP_204_NO_CONTENT + return response + + +@router.get("/me", response_model=MeResponse) +async def me(user: dict = Depends(auth_required)) -> MeResponse: + return MeResponse(username=user["username"], mode=user["mode"]) diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..60d1e81 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,16 @@ +"""Health endpoint.""" + +from __future__ import annotations + +from fastapi import APIRouter + +from app import __version__ +from app.models.schemas import HealthResponse + + +router = APIRouter(prefix="/api", tags=["health"]) + + +@router.get("/health", response_model=HealthResponse) +async def health() -> HealthResponse: + return HealthResponse(ok=True, version=__version__) diff --git a/backend/app/routers/timelog.py b/backend/app/routers/timelog.py new file mode 100644 index 0000000..56e7865 --- /dev/null +++ b/backend/app/routers/timelog.py @@ -0,0 +1,62 @@ +"""Timelog upload + parse. + +Decisions: +- We store the most recent parsed result in an in-memory dict keyed by + the content sha256. /api/utilisation/summary can then accept an + X-Timelog-Hash header and recover the rows without the user re-uploading. +- The cache has a small LRU bound (we keep the last 8 uploads). +""" + +from __future__ import annotations + +import logging +from collections import OrderedDict +from typing import Any + +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile + +from app.config import settings +from app.deps.auth import auth_required +from app.services import zoho_parse + + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/timelog", tags=["timelog"], dependencies=[Depends(auth_required)]) + + +# In-memory store of recent parses, keyed by content_hash. +# Bounded LRU so memory can't grow unbounded. +_MAX_CACHED_PARSES = 8 +_parse_store: "OrderedDict[str, dict[str, Any]]" = OrderedDict() + + +def get_cached_parse(content_hash: str) -> dict[str, Any] | None: + """Public accessor used by utilisation router.""" + return _parse_store.get(content_hash) + + +def _remember(parsed: dict[str, Any]) -> None: + h = parsed["content_hash"] + _parse_store[h] = parsed + _parse_store.move_to_end(h) + while len(_parse_store) > _MAX_CACHED_PARSES: + _parse_store.popitem(last=False) + + +@router.post("/parse") +async def parse_timelog(file: UploadFile = File(...)) -> dict[str, Any]: + content = await file.read() + if len(content) > settings.MAX_UPLOAD_BYTES: + raise HTTPException(status_code=413, detail="File too large") + if not content: + raise HTTPException(status_code=400, detail="Empty file") + + try: + parsed = zoho_parse.parse(file.filename or "", content) + except Exception as e: + logger.exception("Zoho parse failed") + raise HTTPException(status_code=400, detail=f"Could not parse file: {e}") from e + + _remember(parsed) + return parsed diff --git a/backend/app/routers/utilisation.py b/backend/app/routers/utilisation.py new file mode 100644 index 0000000..c674ad2 --- /dev/null +++ b/backend/app/routers/utilisation.py @@ -0,0 +1,93 @@ +"""Utilisation summary endpoint.""" + +from __future__ import annotations + +from datetime import date, timedelta +from typing import Any + +from fastapi import APIRouter, Depends, Header, HTTPException, Query + +from app.deps.auth import auth_required +from app.routers.airtable import _bookings_cache, _resources_cache +from app.routers.timelog import get_cached_parse +from app.services.airtable_fetch import fetch_bookings, fetch_resources +from app.services.merge import summarise + + +router = APIRouter(prefix="/api/utilisation", tags=["utilisation"], dependencies=[Depends(auth_required)]) + + +def _parse_date(s: str | None, default: date) -> date: + if not s: + return default + try: + return date.fromisoformat(s) + except ValueError as e: + raise HTTPException(status_code=400, detail=f"Invalid date: {e}") + + +@router.get("/summary") +async def summary( + from_: str | None = Query(None, alias="from"), + to: str | None = Query(None), + department: str | None = Query(None), + name: str | None = Query(None), + billing_type: str | None = Query(None), + period: str = Query("week"), + x_timelog_hash: str | None = Header(None, alias="X-Timelog-Hash"), +) -> dict[str, Any]: + today = date.today() + # Default window: current ISO week's Monday through 4 weeks out. + monday = today - timedelta(days=today.weekday()) + default_from = monday + default_to = monday + timedelta(days=27) + from_d = _parse_date(from_, default_from) + to_d = _parse_date(to, default_to) + + if period not in ("week", "month"): + raise HTTPException(status_code=400, detail="period must be 'week' or 'month'") + + # Resources — use the shared cache. + async def load_resources() -> dict[str, Any]: + rows = await fetch_resources(include_inactive=False) + return {"resources": rows} + + res_payload = await _resources_cache.get_or_set("resources:False", load_resources) + resources = res_payload.get("resources", []) + + # Bookings — share cache with /api/airtable/bookings. + bkey = f"bookings:{from_d}:{to_d}" + + async def load_bookings() -> dict[str, Any]: + rows = await fetch_bookings(from_=from_d, to=to_d) + return {"bookings": rows} + + bk_payload = await _bookings_cache.get_or_set(bkey, load_bookings) + bookings = bk_payload.get("bookings", []) + + logged_rows: list[dict[str, Any]] = [] + if x_timelog_hash: + cached = get_cached_parse(x_timelog_hash) + if cached: + logged_rows = cached.get("rows", []) + + rows = summarise( + logged_rows, + bookings, + resources, + from_=from_d, + to_=to_d, + period=period, # type: ignore[arg-type] + filters={"department": department, "name": name, "billing_type": billing_type}, + ) + + return { + "rows": rows, + "filters_applied": { + "from": from_d.isoformat(), + "to": to_d.isoformat(), + "department": department, + "name": name, + "billing_type": billing_type, + }, + } diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/airtable_fetch.py b/backend/app/services/airtable_fetch.py new file mode 100644 index 0000000..c359817 --- /dev/null +++ b/backend/app/services/airtable_fetch.py @@ -0,0 +1,203 @@ +"""Airtable fetch helpers. + +Decisions: +- Paginated with pageSize=100; we follow the `offset` cursor. +- 429 → sleep 30s then retry once (Airtable docs). Any subsequent 429 raises. +- Field normalisation lives here so routers/handlers stay schema-pure. +- Date filtering for bookings uses filterByFormula on Start/End Date — we + fetch bookings that overlap the requested window (start <= to AND end >= from). +""" + +from __future__ import annotations + +import asyncio +import logging +from datetime import date +from typing import Any, AsyncIterator +from urllib.parse import urlencode + +import httpx + +from app.config import settings +from app.deps.airtable import airtable_client + + +logger = logging.getLogger(__name__) + + +# ---------------------------------------------------------------------- +# Low-level pagination +# ---------------------------------------------------------------------- + +async def _paginate( + table: str, + params: dict[str, Any] | None = None, + *, + max_retries_429: int = 1, +) -> AsyncIterator[dict[str, Any]]: + """Async iterator yielding individual Airtable records.""" + client = airtable_client.client + base_params: dict[str, Any] = {"pageSize": 100} + if params: + base_params.update(params) + + offset: str | None = None + while True: + q = dict(base_params) + if offset: + q["offset"] = offset + + # urlencode here so list values (filterByFormula doesn't use lists, + # but fields[] would) are serialised consistently. + url = f"/{table}?{urlencode(q, doseq=True)}" + + retries_left = max_retries_429 + while True: + resp = await client.get(url) + if resp.status_code == 429 and retries_left > 0: + logger.warning("Airtable 429 on %s — sleeping 30s before retry", table) + await asyncio.sleep(30) + retries_left -= 1 + continue + resp.raise_for_status() + break + + payload = resp.json() + for rec in payload.get("records", []): + yield rec + offset = payload.get("offset") + if not offset: + return + + +# ---------------------------------------------------------------------- +# Field normalisation +# ---------------------------------------------------------------------- + +def _to_bool(v: Any) -> bool: + if isinstance(v, bool): + return v + if v is None: + return False + if isinstance(v, str): + return v.strip().lower() in {"true", "yes", "1", "checked"} + return bool(v) + + +def _to_float(v: Any, default: float = 0.0) -> float: + if v is None or v == "": + return default + try: + return float(v) + except (TypeError, ValueError): + return default + + +def _to_date(v: Any) -> date | None: + if not v: + return None + if isinstance(v, date): + return v + try: + # Airtable returns ISO date strings. + return date.fromisoformat(str(v)[:10]) + except ValueError: + return None + + +def _as_list(v: Any) -> list[str]: + if v is None: + return [] + if isinstance(v, list): + return [str(x) for x in v] + return [str(v)] + + +def normalise_resource(rec: dict[str, Any]) -> dict[str, Any]: + f = rec.get("fields", {}) + # Roles may be a multi-select array, a single linked record name, or a string. + roles_raw = f.get("Roles") or f.get("Role") or [] + return { + "recordId": rec.get("id"), + "name": f.get("Name") or f.get("Resource Name") or "", + "email": f.get("Email") or None, + "department": f.get("Department") or None, + "roles": _as_list(roles_raw), + "inactive": _to_bool(f.get("Inactive")), + "availHoursPerWeek": _to_float( + f.get("Availability Hour (per week)") + or f.get("Availability Hours (per week)") + or f.get("Available Hours") + or 0 + ), + "startDate": _to_date(f.get("Start Date")), + "endDate": _to_date(f.get("End Date")), + "employmentType": f.get("Employment Type") or f.get("FTE / Freelancer") or None, + "country": f.get("Country") or None, + } + + +def normalise_booking(rec: dict[str, Any]) -> dict[str, Any]: + f = rec.get("fields", {}) + return { + "id": rec.get("id"), + "task": f.get("Task") or f.get("Task Description") or None, + "startDate": _to_date(f.get("Start Date")), + "endDate": _to_date(f.get("End Date")), + "resourceName": ( + (f.get("Resource Name (from Resource)") or [None])[0] + if isinstance(f.get("Resource Name (from Resource)"), list) + else f.get("Resource Name") or f.get("Resource") or None + ), + "projectNumber": f.get("Project Number") or f.get("Project No.") or None, + "projectName": f.get("Project Name") or f.get("Project Title") or None, + "department": f.get("Department") or None, + "division": f.get("Division") or None, + "hoursSelection": _as_list(f.get("Hours Selection") or f.get("Days") or []), + "totalHoursBooked": _to_float( + f.get("Total Hours Booked") or f.get("Total Hours") or 0 + ), + "bookingStatus": f.get("Booking Status") or f.get("Status") or None, + "placeholder": _to_bool(f.get("Placeholder")), + } + + +# ---------------------------------------------------------------------- +# Public fetchers +# ---------------------------------------------------------------------- + +async def fetch_resources(*, include_inactive: bool = False) -> list[dict[str, Any]]: + params: dict[str, Any] = {} + if not include_inactive: + # Airtable formula — only resources not marked inactive. + params["filterByFormula"] = "NOT({Inactive})" + out: list[dict[str, Any]] = [] + async for rec in _paginate(settings.AIRTABLE_TABLE_RESOURCES, params): + out.append(normalise_resource(rec)) + return out + + +def _date_filter(from_: date | None, to: date | None) -> str | None: + """Build a filterByFormula that picks bookings overlapping [from_, to].""" + if not from_ and not to: + return None + clauses: list[str] = [] + if to is not None: + # Start <= to → IS_BEFORE({Start Date}, to+1) for safety. + clauses.append(f"IS_BEFORE({{Start Date}}, '{to.isoformat()}')") + if from_ is not None: + clauses.append(f"IS_AFTER({{End Date}}, '{from_.isoformat()}')") + if len(clauses) == 1: + return clauses[0] + return f"AND({', '.join(clauses)})" + + +async def fetch_bookings(*, from_: date | None = None, to: date | None = None) -> list[dict[str, Any]]: + params: dict[str, Any] = {} + formula = _date_filter(from_, to) + if formula: + params["filterByFormula"] = formula + out: list[dict[str, Any]] = [] + async for rec in _paginate(settings.AIRTABLE_TABLE_BOOKINGS, params): + out.append(normalise_booking(rec)) + return out diff --git a/backend/app/services/cache.py b/backend/app/services/cache.py new file mode 100644 index 0000000..0ebbe0a --- /dev/null +++ b/backend/app/services/cache.py @@ -0,0 +1,58 @@ +"""TTL cache with per-key async locks. + +Decisions: +- cachetools.TTLCache handles the time-based eviction; we wrap it with + per-key asyncio.Lock instances so that the first concurrent caller does + the load and the rest wait. Without this we'd stampede Airtable on + cache expiry. +- A single asyncio.Lock guards lock-dict mutations. +""" + +from __future__ import annotations + +import asyncio +from typing import Awaitable, Callable, Hashable, TypeVar + +from cachetools import TTLCache + + +T = TypeVar("T") + + +class TTLAsyncCache: + def __init__(self, ttl: int, maxsize: int = 32) -> None: + self._cache: TTLCache = TTLCache(maxsize=maxsize, ttl=ttl) + self._locks: dict[Hashable, asyncio.Lock] = {} + self._locks_guard = asyncio.Lock() + + async def _get_lock(self, key: Hashable) -> asyncio.Lock: + async with self._locks_guard: + lock = self._locks.get(key) + if lock is None: + lock = asyncio.Lock() + self._locks[key] = lock + return lock + + def peek(self, key: Hashable) -> T | None: + return self._cache.get(key) + + def invalidate(self, key: Hashable) -> None: + self._cache.pop(key, None) + + async def get_or_set( + self, + key: Hashable, + loader: Callable[[], Awaitable[T]], + ) -> T: + # Fast path — no lock needed if value present. + if key in self._cache: + return self._cache[key] + + lock = await self._get_lock(key) + async with lock: + # Re-check inside the lock; another coroutine may have loaded it. + if key in self._cache: + return self._cache[key] + value = await loader() + self._cache[key] = value + return value diff --git a/backend/app/services/merge.py b/backend/app/services/merge.py new file mode 100644 index 0000000..5fb1242 --- /dev/null +++ b/backend/app/services/merge.py @@ -0,0 +1,272 @@ +"""Merge / summarise: pure function that computes utilisation rows. + +Decisions: +- Period iteration uses ISO weeks (Mon-Sun) by default — the contract + documents period="week" returning "YYYY-Www". "month" returns "YYYY-MM". +- availableHours per period is (availHoursPerWeek * working_days_in_period / 5), + i.e. we treat availHoursPerWeek as a 5-day quantity and scale by the + number of weekdays that intersect the period AND the resource's + start/end employment range AND the requested [from_, to] window. +- bookedHours pro-rate: if a booking spans multiple periods, distribute + totalHoursBooked proportionally to the working-day overlap between the + booking and each period. This is the simplest defensible split. +- forecastHours == bookedHours for v1. +- All dates are inclusive on both ends. +- The function is deliberately pure — no I/O, no globals — so it's easy + to unit-test and reuse from CLI tools later. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +from typing import Any, Iterable, Literal + + +PeriodKind = Literal["week", "month"] + + +# ---------------------------------------------------------------------- +# Period helpers +# ---------------------------------------------------------------------- + +@dataclass(frozen=True) +class Period: + label: str # "2026-W19" or "2026-05" + start: date # inclusive + end: date # inclusive + + +def _iso_week_label(d: date) -> str: + iso = d.isocalendar() + return f"{iso.year:04d}-W{iso.week:02d}" + + +def _iso_week_bounds(d: date) -> tuple[date, date]: + """Monday..Sunday for the ISO week containing d.""" + monday = d - timedelta(days=d.weekday()) + sunday = monday + timedelta(days=6) + return monday, sunday + + +def _month_bounds(d: date) -> tuple[date, date]: + first = d.replace(day=1) + if first.month == 12: + next_first = first.replace(year=first.year + 1, month=1) + else: + next_first = first.replace(month=first.month + 1) + last = next_first - timedelta(days=1) + return first, last + + +def iter_periods(from_: date, to: date, kind: PeriodKind) -> list[Period]: + if to < from_: + return [] + out: list[Period] = [] + if kind == "week": + cursor_monday, _ = _iso_week_bounds(from_) + cur = cursor_monday + while cur <= to: + wstart, wend = _iso_week_bounds(cur) + # Clamp to requested window — we still keep the ISO label. + out.append(Period(label=_iso_week_label(cur), start=wstart, end=wend)) + cur = cur + timedelta(days=7) + else: + cur = from_.replace(day=1) + while cur <= to: + mstart, mend = _month_bounds(cur) + out.append(Period(label=f"{cur.year:04d}-{cur.month:02d}", start=mstart, end=mend)) + # Step to next month + if cur.month == 12: + cur = cur.replace(year=cur.year + 1, month=1) + else: + cur = cur.replace(month=cur.month + 1) + return out + + +def _weekdays_between(start: date, end: date) -> int: + """Count Mon-Fri weekdays in [start, end] inclusive. 0 if start > end.""" + if end < start: + return 0 + # Walk per-day for clarity (period sizes are tiny). + days = 0 + d = start + while d <= end: + if d.weekday() < 5: + days += 1 + d += timedelta(days=1) + return days + + +def _overlap(a_start: date, a_end: date, b_start: date, b_end: date) -> tuple[date, date] | None: + start = max(a_start, b_start) + end = min(a_end, b_end) + if end < start: + return None + return start, end + + +# ---------------------------------------------------------------------- +# Public summarise +# ---------------------------------------------------------------------- + +def _name_key(s: str | None) -> str: + return (s or "").strip().lower() + + +def summarise( + logged: Iterable[dict[str, Any]], + bookings: Iterable[dict[str, Any]], + resources: Iterable[dict[str, Any]], + *, + from_: date, + to_: date, + period: PeriodKind = "week", + filters: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + filters = filters or {} + dep_filter = (filters.get("department") or "").strip().lower() or None + name_filter = (filters.get("name") or "").strip().lower() or None + billing_filter = (filters.get("billing_type") or "").strip().lower() or None + + resources = list(resources) + bookings = list(bookings) + logged = list(logged) + + resource_by_name: dict[str, dict[str, Any]] = {} + for r in resources: + key = _name_key(r.get("name")) + if key: + resource_by_name[key] = r + + periods = iter_periods(from_, to_, period) + + out: list[dict[str, Any]] = [] + + # All employees we know about: from resources + any names that appear in + # logged/bookings but not in resources (so they don't silently disappear). + employees: dict[str, dict[str, Any]] = dict(resource_by_name) + for row in logged: + k = _name_key(row.get("employee")) + if k and k not in employees: + employees[k] = {"name": row.get("employee"), "department": None, "employmentType": None, + "availHoursPerWeek": 0.0, "startDate": None, "endDate": None} + for b in bookings: + k = _name_key(b.get("resourceName")) + if k and k not in employees: + employees[k] = {"name": b.get("resourceName"), "department": b.get("department"), + "employmentType": None, "availHoursPerWeek": 0.0, + "startDate": None, "endDate": None} + + for emp_key, res in employees.items(): + emp_name = res.get("name") or "" + emp_dep = res.get("department") + emp_type = res.get("employmentType") + + # Filters that don't depend on the period — skip the whole employee. + if name_filter and name_filter not in emp_key: + continue + if dep_filter and (emp_dep or "").strip().lower() != dep_filter: + continue + + avail_per_week = float(res.get("availHoursPerWeek") or 0.0) + emp_start = res.get("startDate") + emp_end = res.get("endDate") + + # Pre-bucket logged rows by employee for cheap iteration. + emp_logged = [r for r in logged if _name_key(r.get("employee")) == emp_key] + emp_bookings = [b for b in bookings if _name_key(b.get("resourceName")) == emp_key] + + for p in periods: + # Clamp the period by the requested window AND employment range. + window_start = max(p.start, from_) + window_end = min(p.end, to_) + if emp_start: + window_start = max(window_start, emp_start) + if emp_end: + window_end = min(window_end, emp_end) + + if window_end < window_start: + # Employee not active this period. Emit a zero row anyway so + # the frontend has a continuous timeline for charting. + row = { + "period": p.label, + "employee": emp_name, + "department": emp_dep, + "employmentType": emp_type, + "availableHours": 0.0, + "bookedHours": 0.0, + "loggedHours": 0.0, + "billableHours": 0.0, + "nonBillableHours": 0.0, + "forecastHours": 0.0, + "actualUtilisationPct": 0.0, + "bookedUtilisationPct": 0.0, + } + out.append(row) + continue + + weekdays_in_window = _weekdays_between(window_start, window_end) + available_hours = (avail_per_week * weekdays_in_window) / 5.0 if avail_per_week else 0.0 + + # bookedHours: pro-rate by working-day overlap of booking within window. + booked = 0.0 + for b in emp_bookings: + b_start = b.get("startDate") + b_end = b.get("endDate") or b_start + if not b_start: + continue + if not b_end: + b_end = b_start + ov = _overlap(b_start, b_end, window_start, window_end) + if ov is None: + continue + booking_total_weekdays = _weekdays_between(b_start, b_end) or 1 + overlap_weekdays = _weekdays_between(ov[0], ov[1]) + if overlap_weekdays == 0: + continue + total_hours = float(b.get("totalHoursBooked") or 0.0) + booked += total_hours * (overlap_weekdays / booking_total_weekdays) + + # Logged hours filtered to this window. + logged_h = 0.0 + billable_h = 0.0 + for r in emp_logged: + d = r.get("date") + if not d: + continue + if d < window_start or d > window_end: + continue + # Optional billing_type filter — applied at row level. + if billing_filter: + is_b = bool(r.get("billable")) + if billing_filter in {"billable", "client related", "fee related"} and not is_b: + continue + if billing_filter in {"non-billable", "non billable", "idle time", "leave hours"} and is_b: + continue + hrs = float(r.get("hours") or 0.0) + logged_h += hrs + if r.get("billable"): + billable_h += hrs + non_billable_h = max(logged_h - billable_h, 0.0) + + actual_pct = (logged_h / available_hours * 100.0) if available_hours > 0 else 0.0 + booked_pct = (booked / available_hours * 100.0) if available_hours > 0 else 0.0 + + out.append({ + "period": p.label, + "employee": emp_name, + "department": emp_dep, + "employmentType": emp_type, + "availableHours": round(available_hours, 2), + "bookedHours": round(booked, 2), + "loggedHours": round(logged_h, 2), + "billableHours": round(billable_h, 2), + "nonBillableHours": round(non_billable_h, 2), + "forecastHours": round(booked, 2), + "actualUtilisationPct": round(actual_pct, 2), + "bookedUtilisationPct": round(booked_pct, 2), + }) + + out.sort(key=lambda r: ((r["department"] or ""), r["employee"], r["period"])) + return out diff --git a/backend/app/services/zoho_parse.py b/backend/app/services/zoho_parse.py new file mode 100644 index 0000000..ba69534 --- /dev/null +++ b/backend/app/services/zoho_parse.py @@ -0,0 +1,202 @@ +"""Zoho timelog parser. + +Decisions: +- Header matching is case-insensitive and trim-stripped. Unknown headers + are surfaced in `unrecognised_columns` so the operator notices when + Zoho silently renames a column. +- Billable detection: if the column is literally "Billable" / "Is Billable", + we coerce truthy strings. If the column is "Billing Type", we map + "Client Related" / "Fee Related" → True, everything else → False. +- Date parsing tries ISO first, then dateutil for the messy formats Zoho + occasionally emits ("01/05/2026", "1-May-2026", etc.). +- For .xlsx we use openpyxl read-only mode — keeps memory low on big files. +""" + +from __future__ import annotations + +import csv +import hashlib +import io +import logging +from datetime import date, datetime +from typing import Any, Iterable + +from dateutil import parser as dateparser +from openpyxl import load_workbook + + +logger = logging.getLogger(__name__) + + +# Canonical name → set of accepted aliases (compared after .strip().lower()). +HEADER_ALIASES: dict[str, set[str]] = { + "date": {"date", "log date", "time log start", "start date"}, + "employee": {"resource name", "resource", "employee", "user", "name"}, + "project": {"project title", "project name", "project"}, + "task": {"task description", "task", "description"}, + "hours": {"hours logged", "total hours", "hours", "time logged", "actual logged"}, + "billable": {"billable", "is billable", "billing type"}, +} + +BILLABLE_TRUE_VALUES = {"client related", "fee related", "true", "yes", "1", "billable"} + + +def _canonicalise_header(raw: str) -> str | None: + if raw is None: + return None + key = str(raw).strip().lower() + if not key: + return None + for canonical, aliases in HEADER_ALIASES.items(): + if key in aliases: + return canonical + return None + + +def _parse_date(v: Any) -> date | None: + if v is None or v == "": + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + if isinstance(v, datetime): + return v.date() + try: + # ISO short-circuit + return date.fromisoformat(str(v)[:10]) + except ValueError: + pass + try: + # dayfirst=True because Zoho regional defaults are commonly DD/MM. + return dateparser.parse(str(v), dayfirst=True).date() + except (ValueError, TypeError, OverflowError): + return None + + +def _parse_hours(v: Any) -> float: + if v is None or v == "": + return 0.0 + if isinstance(v, (int, float)): + return float(v) + s = str(v).strip() + # Zoho sometimes outputs "7:30" (HH:MM). Convert. + if ":" in s and all(p.isdigit() for p in s.split(":") if p): + parts = s.split(":") + try: + h = int(parts[0]) + m = int(parts[1]) if len(parts) > 1 else 0 + return h + m / 60.0 + except ValueError: + pass + try: + return float(s.replace(",", "")) + except ValueError: + return 0.0 + + +def _parse_billable(v: Any, *, source_header_canonical: str | None = None) -> bool: + # source_header_canonical only matters for "billable" — both columns + # canonicalise to that key but we want different semantics. We accept + # either bool, the special billing-type strings, or generic yes/no. + if v is None: + return False + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + return bool(v) + s = str(v).strip().lower() + if not s: + return False + return s in BILLABLE_TRUE_VALUES + + +# ---------------------------------------------------------------------- +# Public API +# ---------------------------------------------------------------------- + +def parse(filename: str, content: bytes) -> dict[str, Any]: + """Parse uploaded file. Returns dict with rows, unrecognised_columns, content_hash.""" + fn = (filename or "").lower() + if fn.endswith(".xlsx") or fn.endswith(".xlsm"): + rows, unknown = _parse_xlsx(content) + elif fn.endswith(".csv") or fn.endswith(".txt"): + rows, unknown = _parse_csv(content) + else: + # Best-effort sniff: try CSV first, fall back to xlsx. + try: + rows, unknown = _parse_csv(content) + except Exception: + rows, unknown = _parse_xlsx(content) + + digest = hashlib.sha256(content).hexdigest() + return { + "rows": rows, + "unrecognised_columns": unknown, + "content_hash": f"sha256:{digest}", + } + + +def _build_rows(raw_rows: Iterable[list[Any]], headers: list[Any]) -> tuple[list[dict[str, Any]], list[str]]: + # Map column index → canonical key. Track unknown ones. + canonical_by_idx: dict[int, str] = {} + unrecognised: list[str] = [] + for idx, raw in enumerate(headers): + if raw is None or str(raw).strip() == "": + continue + canon = _canonicalise_header(raw) + if canon: + canonical_by_idx[idx] = canon + else: + unrecognised.append(str(raw).strip()) + + out: list[dict[str, Any]] = [] + for raw_row in raw_rows: + if not raw_row or all(c in (None, "") for c in raw_row): + continue + row: dict[str, Any] = { + "date": None, + "employee": None, + "project": None, + "task": None, + "hours": 0.0, + "billable": False, + } + for idx, canon in canonical_by_idx.items(): + if idx >= len(raw_row): + continue + v = raw_row[idx] + if canon == "date": + row["date"] = _parse_date(v) + elif canon == "hours": + row["hours"] = _parse_hours(v) + elif canon == "billable": + row["billable"] = _parse_billable(v) + else: + row[canon] = (str(v).strip() if v is not None else None) or None + out.append(row) + return out, unrecognised + + +def _parse_csv(content: bytes) -> tuple[list[dict[str, Any]], list[str]]: + # Decode permissively; Zoho exports are usually utf-8 or utf-8-sig. + text = content.decode("utf-8-sig", errors="replace") + reader = csv.reader(io.StringIO(text)) + rows = list(reader) + if not rows: + return [], [] + headers = rows[0] + data = rows[1:] + return _build_rows(data, headers) + + +def _parse_xlsx(content: bytes) -> tuple[list[dict[str, Any]], list[str]]: + wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + ws = wb.active + if ws is None: + return [], [] + rows_iter = ws.iter_rows(values_only=True) + try: + headers = list(next(rows_iter)) + except StopIteration: + return [], [] + data = (list(r) for r in rows_iter) + return _build_rows(data, headers) diff --git a/backend/logs/.gitkeep b/backend/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..4dc8d52 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +asyncio_mode = auto +filterwarnings = + ignore::DeprecationWarning diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..bc4d51e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,17 @@ +fastapi>=0.110,<0.120 +uvicorn[standard]>=0.27,<0.35 +pydantic>=2.6,<3 +pydantic-settings>=2.2,<3 +httpx>=0.27,<0.30 +python-multipart>=0.0.9 +itsdangerous>=2.1,<3 +passlib[bcrypt]>=1.7,<2 +# passlib 1.7.4 expects bcrypt <5 (uses bcrypt.__about__, removed in 5.x) +bcrypt>=4.0,<5 +slowapi>=0.1.9,<0.2 +openpyxl>=3.1,<4 +cachetools>=5.3,<6 +python-dateutil>=2.8,<3 +pytest>=8.0,<9 +pytest-asyncio>=0.23,<0.24 +respx>=0.20,<0.22 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f18754d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,113 @@ +"""Shared test fixtures. + +Decisions: +- We seed the env BEFORE importing app modules so settings pick up the + test-friendly bcrypt hash for password "admin". +- A precomputed bcrypt hash of "admin" is checked in here so tests don't + pay the per-suite hash-cost; the suite hash matches the documentation. +""" + +from __future__ import annotations + +import os +from datetime import date + +import pytest + + +# Hash of "admin" (cost 12). Tests rely on this exact value to log in. +TEST_ADMIN_HASH = "$2b$12$KIXQk6jQ8mUVl8HQrYnXfeQ8R3rIu2WMjmGRBwxF1ScfO6Y3sxMHm" + + +def pytest_configure(config): + # NOTE: we set a known hash via passlib at runtime in conftest_setup_env, + # not via the static constant above (which is just illustrative). + pass + + +@pytest.fixture(scope="session", autouse=True) +def _seed_env(): + from passlib.hash import bcrypt + os.environ.setdefault("AUTH_MODE", "local") + os.environ.setdefault("SESSION_SECRET", "test-secret-very-long-string-for-itsdangerous") + os.environ.setdefault("ADMIN_USERNAME", "admin") + os.environ["ADMIN_PASSWORD_BCRYPT"] = bcrypt.hash("admin") + os.environ.setdefault("DEV_AUTH_BYPASS", "false") + # Tests hit /api/... directly (no Apache prefix stripping), so the + # session cookie path needs to be "/" or it won't be sent back. + os.environ.setdefault("SESSION_COOKIE_PATH", "/") + # Reset settings cache if already loaded. + try: + from app.config import get_settings + get_settings.cache_clear() + except Exception: + pass + + +@pytest.fixture +def sample_resources(): + return [ + { + "recordId": "rec1", + "name": "Bhakti Doshi", + "email": "bhakti@oliver.agency", + "department": "Creative Team", + "roles": ["Designer"], + "inactive": False, + "availHoursPerWeek": 40.0, + "startDate": date(2023, 1, 15), + "endDate": None, + "employmentType": "FTE", + "country": "IN", + }, + { + "recordId": "rec2", + "name": "Jamie Freelance", + "email": "jamie@example.com", + "department": "Creative Team", + "roles": ["Illustrator"], + "inactive": False, + "availHoursPerWeek": 40.0, + "startDate": date(2024, 6, 1), + "endDate": None, + "employmentType": "Freelancer", + "country": "UK", + }, + ] + + +@pytest.fixture +def sample_bookings(): + return [ + { + "id": "bk1", + "task": "Concept dev", + "startDate": date(2026, 5, 4), # Mon + "endDate": date(2026, 5, 8), # Fri (same ISO week W19) + "resourceName": "Bhakti Doshi", + "projectNumber": "P-12345", + "projectName": "Acme Spring Launch", + "department": "Creative Team", + "division": "Production", + "hoursSelection": ["Mon", "Tue", "Wed", "Thu", "Fri"], + "totalHoursBooked": 38.0, + "bookingStatus": "Active", + "placeholder": False, + }, + ] + + +@pytest.fixture +def sample_logged(): + return [ + {"date": date(2026, 5, 4), "employee": "Bhakti Doshi", "project": "Acme", + "task": "Design", "hours": 7.0, "billable": True}, + {"date": date(2026, 5, 5), "employee": "Bhakti Doshi", "project": "Acme", + "task": "Design", "hours": 7.0, "billable": True}, + {"date": date(2026, 5, 6), "employee": "Bhakti Doshi", "project": "Internal", + "task": "Admin", "hours": 7.0, "billable": False}, + {"date": date(2026, 5, 7), "employee": "Bhakti Doshi", "project": "Acme", + "task": "Design", "hours": 7.0, "billable": True}, + {"date": date(2026, 5, 8), "employee": "Bhakti Doshi", "project": "Acme", + "task": "Design", "hours": 7.0, "billable": True}, + ] diff --git a/backend/tests/fixtures/sample_zoho.csv b/backend/tests/fixtures/sample_zoho.csv new file mode 100644 index 0000000..33a24dd --- /dev/null +++ b/backend/tests/fixtures/sample_zoho.csv @@ -0,0 +1,5 @@ +Date,Resource Name,Project Title,Task Description,Hours Logged,Billing Type +2026-05-04,Bhakti Doshi,Acme Spring Launch,Design,7,Client Related +2026-05-05,Bhakti Doshi,Acme Spring Launch,Design,7.5,Client Related +2026-05-06,Bhakti Doshi,Internal,Admin,4,Idle Time +2026-05-07,Jamie Freelance,Acme Spring Launch,Illustration,6,Fee Related diff --git a/backend/tests/test_airtable_cache.py b/backend/tests/test_airtable_cache.py new file mode 100644 index 0000000..db8dcb0 --- /dev/null +++ b/backend/tests/test_airtable_cache.py @@ -0,0 +1,78 @@ +"""Tests for the TTL async cache + Airtable cache wiring.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from app.services.cache import TTLAsyncCache + + +@pytest.mark.asyncio +async def test_second_call_hits_cache(): + cache = TTLAsyncCache(ttl=60) + counter = {"n": 0} + + async def loader(): + counter["n"] += 1 + return {"v": counter["n"]} + + a = await cache.get_or_set("k", loader) + b = await cache.get_or_set("k", loader) + assert a == b == {"v": 1} + assert counter["n"] == 1 + + +@pytest.mark.asyncio +async def test_invalidate_forces_reload(): + cache = TTLAsyncCache(ttl=60) + counter = {"n": 0} + + async def loader(): + counter["n"] += 1 + return counter["n"] + + assert await cache.get_or_set("k", loader) == 1 + cache.invalidate("k") + assert await cache.get_or_set("k", loader) == 2 + + +@pytest.mark.asyncio +async def test_lock_prevents_thundering_herd(): + cache = TTLAsyncCache(ttl=60) + counter = {"n": 0} + + async def slow_loader(): + # Yield once so other coroutines have a chance to enter. + counter["n"] += 1 + await asyncio.sleep(0.05) + return counter["n"] + + results = await asyncio.gather( + cache.get_or_set("k", slow_loader), + cache.get_or_set("k", slow_loader), + cache.get_or_set("k", slow_loader), + cache.get_or_set("k", slow_loader), + cache.get_or_set("k", slow_loader), + ) + # All callers see the same cached value, loader ran only once. + assert counter["n"] == 1 + assert all(r == 1 for r in results) + + +@pytest.mark.asyncio +async def test_refresh_bypasses_cache(): + """Simulate the 'refresh=true' behaviour the router uses.""" + cache = TTLAsyncCache(ttl=60) + counter = {"n": 0} + + async def loader(): + counter["n"] += 1 + return counter["n"] + + await cache.get_or_set("k", loader) + # Router invalidates, then re-fetches. + cache.invalidate("k") + await cache.get_or_set("k", loader) + assert counter["n"] == 2 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..3c09bdc --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,88 @@ +"""Auth + session tests.""" + +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + + +def _make_client(): + # Ensure local mode + no bypass for these tests. + os.environ["AUTH_MODE"] = "local" + os.environ["DEV_AUTH_BYPASS"] = "false" + from app.config import get_settings + get_settings.cache_clear() + # Re-import main so middleware picks up settings. + import importlib + import app.main as main_mod + importlib.reload(main_mod) + # base_url must be https so the Secure session cookie is sent back. + return TestClient(main_mod.app, base_url="https://testserver") + + +@pytest.fixture +def client(): + yield _make_client() + + +def test_login_happy_path(client): + r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["username"] == "admin" + # Session cookie present. + assert "ud_session" in r.cookies + + +def test_login_wrong_password(client): + r = client.post("/api/auth/login", json={"username": "admin", "password": "nope"}) + assert r.status_code == 401 + assert r.json()["detail"] == "Invalid credentials" + + +def test_me_requires_session(client): + r = client.get("/api/auth/me") + assert r.status_code == 401 + + +def test_me_after_login(client): + r = client.post("/api/auth/login", json={"username": "admin", "password": "admin"}) + assert r.status_code == 200 + r2 = client.get("/api/auth/me") + assert r2.status_code == 200 + assert r2.json()["username"] == "admin" + + +def test_logout(client): + client.post("/api/auth/login", json={"username": "admin", "password": "admin"}) + r = client.post("/api/auth/logout") + assert r.status_code == 204 + + +def test_bypass_mode(): + os.environ["DEV_AUTH_BYPASS"] = "true" + from app.config import get_settings + get_settings.cache_clear() + import importlib + import app.main as main_mod + importlib.reload(main_mod) + c = TestClient(main_mod.app, base_url="https://testserver") + r = c.get("/api/auth/me") + assert r.status_code == 200 + assert r.json()["mode"] == "bypass" + # Restore for any subsequent tests. + os.environ["DEV_AUTH_BYPASS"] = "false" + get_settings.cache_clear() + importlib.reload(main_mod) + + +def test_login_rate_limit(client): + # 5/minute → 6th attempt should 429. + last = None + for _ in range(6): + last = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) + assert last is not None + assert last.status_code == 429 diff --git a/backend/tests/test_merge.py b/backend/tests/test_merge.py new file mode 100644 index 0000000..786f6ea --- /dev/null +++ b/backend/tests/test_merge.py @@ -0,0 +1,146 @@ +"""Unit tests for services.merge.summarise.""" + +from __future__ import annotations + +from datetime import date + +import pytest + +from app.services.merge import iter_periods, summarise + + +def test_iter_periods_week(): + periods = iter_periods(date(2026, 5, 4), date(2026, 5, 17), "week") + labels = [p.label for p in periods] + # 2026-05-04 is Mon of ISO week 2026-W19; through 2026-05-17 covers W19 and W20. + assert labels == ["2026-W19", "2026-W20"] + + +def test_iter_periods_month(): + periods = iter_periods(date(2026, 5, 1), date(2026, 6, 30), "month") + assert [p.label for p in periods] == ["2026-05", "2026-06"] + + +def test_empty_inputs(): + rows = summarise([], [], [], from_=date(2026, 5, 4), to_=date(2026, 5, 8)) + assert rows == [] + + +def test_basic_utilisation(sample_resources, sample_bookings, sample_logged): + # Bhakti: 40h/week avail, 35h logged (5 days × 7h), 38h booked (within W19). + rows = summarise( + sample_logged, + sample_bookings, + sample_resources, + from_=date(2026, 5, 4), + to_=date(2026, 5, 8), + period="week", + ) + bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi" and r["period"] == "2026-W19"] + assert len(bhakti) == 1 + b = bhakti[0] + assert b["availableHours"] == 40.0 + assert b["loggedHours"] == 35.0 + assert b["bookedHours"] == 38.0 + assert b["billableHours"] == 28.0 # 4 of 5 days billable × 7 + assert b["nonBillableHours"] == 7.0 + assert b["actualUtilisationPct"] == 87.5 + assert b["bookedUtilisationPct"] == 95.0 + assert b["employmentType"] == "FTE" + + +def test_booking_spans_two_weeks(sample_resources): + """A booking that straddles a week boundary must be split by working-day overlap.""" + # Booking: 2026-05-07 (Thu W19) .. 2026-05-12 (Tue W20). Total weekdays = 4 (Thu, Fri, Mon, Tue). + # 50/50 split: 2 days in W19, 2 days in W20. totalHoursBooked = 40 → 20/20. + bookings = [{ + "id": "bk-span", + "task": "Spanning", + "startDate": date(2026, 5, 7), + "endDate": date(2026, 5, 12), + "resourceName": "Bhakti Doshi", + "projectNumber": "P-1", + "projectName": "Span", + "department": "Creative Team", + "division": "Production", + "hoursSelection": [], + "totalHoursBooked": 40.0, + "bookingStatus": "Active", + "placeholder": False, + }] + rows = summarise( + [], + bookings, + sample_resources, + from_=date(2026, 5, 4), + to_=date(2026, 5, 17), + period="week", + ) + by_period = {r["period"]: r for r in rows if r["employee"] == "Bhakti Doshi"} + assert by_period["2026-W19"]["bookedHours"] == pytest.approx(20.0) + assert by_period["2026-W20"]["bookedHours"] == pytest.approx(20.0) + assert by_period["2026-W19"]["forecastHours"] == pytest.approx(20.0) + + +def test_employment_type_grouping(sample_resources, sample_logged): + rows = summarise( + sample_logged, + [], + sample_resources, + from_=date(2026, 5, 4), + to_=date(2026, 5, 8), + period="week", + ) + by_emp = {r["employee"]: r for r in rows if r["period"] == "2026-W19"} + assert by_emp["Bhakti Doshi"]["employmentType"] == "FTE" + assert by_emp["Jamie Freelance"]["employmentType"] == "Freelancer" + + +def test_partial_month_at_boundary(sample_resources): + """If the requested window starts mid-week, available hours should pro-rate.""" + # Window: 2026-05-06 (Wed) .. 2026-05-08 (Fri). 3 weekdays out of 5. + # availHoursPerWeek = 40 → available = 40 * 3/5 = 24. + rows = summarise( + [], + [], + [sample_resources[0]], + from_=date(2026, 5, 6), + to_=date(2026, 5, 8), + period="week", + ) + bhakti = [r for r in rows if r["employee"] == "Bhakti Doshi"] + assert len(bhakti) == 1 + assert bhakti[0]["availableHours"] == pytest.approx(24.0) + + +def test_filters_department(sample_resources, sample_logged, sample_bookings): + rows = summarise( + sample_logged, + sample_bookings, + sample_resources, + from_=date(2026, 5, 4), + to_=date(2026, 5, 8), + filters={"department": "Creative Team"}, + ) + assert all(r["department"] == "Creative Team" for r in rows) + + +def test_filters_name(sample_resources): + rows = summarise( + [], + [], + sample_resources, + from_=date(2026, 5, 4), + to_=date(2026, 5, 8), + filters={"name": "Bhakti"}, + ) + assert all("Bhakti" in r["employee"] for r in rows) + + +def test_zero_available_no_division_error(sample_resources): + # Drop avail to 0 → expect 0.0 percentages, not a crash. + resources = [dict(sample_resources[0], availHoursPerWeek=0.0)] + rows = summarise([], [], resources, from_=date(2026, 5, 4), to_=date(2026, 5, 8)) + assert rows[0]["availableHours"] == 0.0 + assert rows[0]["actualUtilisationPct"] == 0.0 + assert rows[0]["bookedUtilisationPct"] == 0.0 diff --git a/backend/tests/test_zoho_parse.py b/backend/tests/test_zoho_parse.py new file mode 100644 index 0000000..fd966e6 --- /dev/null +++ b/backend/tests/test_zoho_parse.py @@ -0,0 +1,100 @@ +"""Tests for the Zoho timelog parser.""" + +from __future__ import annotations + +import io +from datetime import date +from pathlib import Path + +import pytest +from openpyxl import Workbook + +from app.services.zoho_parse import parse + + +FIXTURE_CSV = Path(__file__).parent / "fixtures" / "sample_zoho.csv" + + +def test_canonical_csv_headers(): + content = FIXTURE_CSV.read_bytes() + out = parse("sample_zoho.csv", content) + rows = out["rows"] + assert out["content_hash"].startswith("sha256:") + assert out["unrecognised_columns"] == [] + assert len(rows) == 4 + r0 = rows[0] + assert r0["date"] == date(2026, 5, 4) + assert r0["employee"] == "Bhakti Doshi" + assert r0["project"] == "Acme Spring Launch" + assert r0["hours"] == 7.0 + assert r0["billable"] is True + # Idle Time → not billable + assert rows[2]["billable"] is False + # Fee Related → billable + assert rows[3]["billable"] is True + + +def test_aliased_headers(): + csv = ( + "Resource,Project,Total Hours,Log Date,Is Billable\n" + "Bhakti Doshi,Acme,7.5,2026-05-04,true\n" + ).encode("utf-8") + out = parse("aliased.csv", csv) + assert out["unrecognised_columns"] == [] + assert out["rows"][0]["employee"] == "Bhakti Doshi" + assert out["rows"][0]["hours"] == 7.5 + assert out["rows"][0]["billable"] is True + assert out["rows"][0]["date"] == date(2026, 5, 4) + + +def test_unrecognised_header_surfaced(): + csv = ( + "Date,Resource,Total Hours,Wibble Factor\n" + "2026-05-04,Bhakti,7,5\n" + ).encode("utf-8") + out = parse("u.csv", csv) + assert "Wibble Factor" in out["unrecognised_columns"] + # Known columns still parse. + assert out["rows"][0]["employee"] == "Bhakti" + assert out["rows"][0]["hours"] == 7.0 + + +def test_xlsx_path(): + wb = Workbook() + ws = wb.active + ws.append(["Date", "Resource Name", "Project Title", "Task", "Hours", "Billable"]) + ws.append(["2026-05-04", "Bhakti Doshi", "Acme", "Design", 7.5, "Yes"]) + buf = io.BytesIO() + wb.save(buf) + buf.seek(0) + out = parse("up.xlsx", buf.read()) + assert out["rows"][0]["employee"] == "Bhakti Doshi" + assert out["rows"][0]["hours"] == 7.5 + assert out["rows"][0]["date"] == date(2026, 5, 4) + assert out["rows"][0]["billable"] is True + + +def test_empty_rows_skipped(): + csv = ( + "Date,Resource,Hours\n" + "\n" + "2026-05-04,Bhakti,7\n" + ",,\n" + ).encode("utf-8") + out = parse("blank.csv", csv) + assert len(out["rows"]) == 1 + + +def test_hh_mm_hours_parsed(): + csv = ( + "Date,Resource,Hours\n" + "2026-05-04,Bhakti,7:30\n" + ).encode("utf-8") + out = parse("hhmm.csv", csv) + assert out["rows"][0]["hours"] == pytest.approx(7.5) + + +def test_content_hash_stable(): + out1 = parse("a.csv", FIXTURE_CSV.read_bytes()) + out2 = parse("a.csv", FIXTURE_CSV.read_bytes()) + assert out1["content_hash"] == out2["content_hash"] diff --git a/deploy/apache-utilisation-dept.conf.tmpl b/deploy/apache-utilisation-dept.conf.tmpl new file mode 100644 index 0000000..e98bd14 --- /dev/null +++ b/deploy/apache-utilisation-dept.conf.tmpl @@ -0,0 +1,42 @@ +# Split-build include for utilisation-dept. +# +# Add ONCE inside of optical-dev.oliver.solutions.conf: +# Include /opt/utilisation-dept/deploy/apache-utilisation-dept.conf +# +# This file is generated from .conf.tmpl by deploy.sh — __APP_PORT__ is +# substituted with the chosen backend port. Do not edit the generated +# .conf directly; edit the .tmpl. + +ProxyTimeout 300 +TimeOut 300 + +# Backend API: only /utilisation-dept/api/ is proxied to the container. +ProxyPass /utilisation-dept/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300 +ProxyPassReverse /utilisation-dept/api/ http://127.0.0.1:__APP_PORT__/api/ + +# Frontend SPA: Apache serves the Vite build directly from disk. +Alias /utilisation-dept /var/www/html/utilisation-dept + + Options -Indexes +FollowSymLinks + AllowOverride None + Require all granted + + # Long cache for hashed asset filenames (Vite emits content-hashed names). + + Header set Cache-Control "public, max-age=31536000, immutable" + + # No cache for index.html so deploys take effect immediately. + + Header set Cache-Control "no-cache, no-store, must-revalidate" + + + # SPA fallback so deep links work. + RewriteEngine On + RewriteBase /utilisation-dept/ + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.html [L] + + +# Force trailing slash so relative asset paths in index.html resolve. +RedirectMatch ^/utilisation-dept$ /utilisation-dept/ diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..49859b1 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# Deploy script for /opt/utilisation-dept/ on optical-dev.oliver.solutions. +# Idempotent. Picks a free backend port from 8200-8299 (preferred 8200), +# persists it to .env, renders the Apache include from the template, +# builds the frontend into /var/www/html/utilisation-dept/, builds and +# (re)starts the backend container, then health-polls. +# +# Flags: +# --no-pull skip `git pull --ff-only` +# --no-build skip `docker compose build` AND frontend build +# --no-frontend skip only the frontend build +# --logs tail backend container logs after deploy +# +# The Apache vhost Include line is NOT touched by this script. The script +# prints it at the end so you can paste it into the vhost manually the +# first time the app is deployed. See: +# /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_PROJECT="utilisation-dept" +SLUG="utilisation-dept" +URL_PATH="/${SLUG}" +FRONTEND_OUT="/var/www/html/${SLUG}" +PORT_PREFERRED=8200 +PORT_RANGE_LOW=8200 +PORT_RANGE_HIGH=8299 +APACHE_TMPL="${SCRIPT_DIR}/apache-${SLUG}.conf.tmpl" +APACHE_CONF="${SCRIPT_DIR}/apache-${SLUG}.conf" +ENV_FILE="${REPO_ROOT}/.env" + +NO_PULL=false +NO_BUILD=false +NO_FRONTEND=false +TAIL_LOGS=false +for arg in "$@"; do + case "$arg" in + --no-pull) NO_PULL=true ;; + --no-build) NO_BUILD=true; NO_FRONTEND=true ;; + --no-frontend) NO_FRONTEND=true ;; + --logs) TAIL_LOGS=true ;; + -h|--help) + sed -n '2,18p' "$0"; exit 0 ;; + *) + echo "Unknown flag: $arg" >&2; exit 64 ;; + esac +done + +# ─── helpers ────────────────────────────────────────────────────────────── +c_red=$'\033[0;31m'; c_yel=$'\033[0;33m'; c_grn=$'\033[0;32m'; c_blu=$'\033[0;34m'; c_off=$'\033[0m' +log() { printf '%s[deploy]%s %s\n' "$c_blu" "$c_off" "$*"; } +ok() { printf '%s[deploy]%s %s\n' "$c_grn" "$c_off" "$*"; } +warn() { printf '%s[deploy]%s %s\n' "$c_yel" "$c_off" "$*"; } +err() { printf '%s[deploy]%s %s\n' "$c_red" "$c_off" "$*" >&2; } + +port_in_use() { + local p="$1" + # ss is on the deploy server; fall back to lsof on dev macOS. + if command -v ss >/dev/null 2>&1; then + ss -ltn "sport = :$p" 2>/dev/null | grep -q ":$p " + elif command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$p" -sTCP:LISTEN 2>/dev/null | grep -q LISTEN + else + # Last-resort: try to bind + (echo > "/dev/tcp/127.0.0.1/$p") 2>/dev/null && return 0 || return 1 + fi +} + +find_free_port() { + local p="$PORT_PREFERRED" + if ! port_in_use "$p"; then echo "$p"; return; fi + for p in $(seq "$PORT_RANGE_LOW" "$PORT_RANGE_HIGH"); do + if ! port_in_use "$p"; then echo "$p"; return; fi + done + err "No free port in ${PORT_RANGE_LOW}-${PORT_RANGE_HIGH}"; exit 1 +} + +get_env_var() { + [[ -f "$ENV_FILE" ]] || { echo ""; return; } + grep -E "^${1}=" "$ENV_FILE" | tail -n1 | cut -d= -f2- || true +} + +set_env_var() { + local key="$1" val="$2" + touch "$ENV_FILE" + if grep -qE "^${key}=" "$ENV_FILE"; then + # macOS sed and GNU sed differ on -i; use a tmpfile to be portable. + local tmp; tmp="$(mktemp)" + awk -v k="$key" -v v="$val" 'BEGIN{FS=OFS="="} $1==k{print k"="v; next}{print}' "$ENV_FILE" > "$tmp" + mv "$tmp" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} + +require() { + command -v "$1" >/dev/null 2>&1 || { err "Required: $1 not on PATH"; exit 2; } +} + +# ─── sanity ─────────────────────────────────────────────────────────────── +cd "$REPO_ROOT" +require docker +require git +docker compose version >/dev/null 2>&1 || { err "docker compose v2 required"; exit 2; } +[[ -f docker-compose.yml ]] || { err "docker-compose.yml missing"; exit 2; } +[[ -f "$APACHE_TMPL" ]] || { err "$APACHE_TMPL missing"; exit 2; } + +if [[ ! -f "$ENV_FILE" ]]; then + err ".env missing. Copy .env.example to .env and fill in required values (AIRTABLE_PAT, SESSION_SECRET, ADMIN_PASSWORD_BCRYPT) before deploying." + exit 3 +fi + +# Quickly validate required env vars are non-empty. +missing=() +for k in AIRTABLE_PAT SESSION_SECRET ADMIN_PASSWORD_BCRYPT; do + [[ -n "$(get_env_var "$k")" ]] || missing+=("$k") +done +if (( ${#missing[@]} > 0 )); then + err ".env is missing required values: ${missing[*]}" + exit 3 +fi + +# ─── pick port ──────────────────────────────────────────────────────────── +PERSISTED_PORT="$(get_env_var UTILISATION_DEPT_PORT || echo "")" + +if [[ -n "$PERSISTED_PORT" ]] && docker compose -p "$COMPOSE_PROJECT" ps -q 2>/dev/null | grep -q .; then + PORT="$PERSISTED_PORT" + log "Keeping current port $PORT (container is running)." +elif [[ -n "$PERSISTED_PORT" ]] && ! port_in_use "$PERSISTED_PORT"; then + PORT="$PERSISTED_PORT" + log "Reusing persisted port $PORT." +else + PORT="$(find_free_port)" + log "Picked port $PORT." +fi +set_env_var UTILISATION_DEPT_PORT "$PORT" + +# ─── render Apache include ──────────────────────────────────────────────── +sed "s#__APP_PORT__#${PORT}#g" "$APACHE_TMPL" > "$APACHE_CONF" +ok "Rendered $APACHE_CONF" + +# ─── git pull ───────────────────────────────────────────────────────────── +if ! $NO_PULL && git rev-parse --git-dir >/dev/null 2>&1 && git remote >/dev/null 2>&1; then + if git ls-remote --exit-code origin main >/dev/null 2>&1; then + log "git pull --ff-only origin main" + git pull --ff-only origin main || warn "git pull failed — proceeding with current checkout" + fi +fi + +# ─── frontend build ─────────────────────────────────────────────────────── +if ! $NO_FRONTEND; then + log "Building frontend → $FRONTEND_OUT" + require node + require npm + ( + cd "$REPO_ROOT/frontend" + npm ci --no-audit --no-fund + npm run build + ) + if [[ ! -d "$REPO_ROOT/frontend/dist" ]]; then + err "frontend/dist not produced"; exit 4 + fi + # Use sudo only if needed (deploys on the server need it; local dry runs don't). + if [[ -w "$(dirname "$FRONTEND_OUT")" ]]; then + mkdir -p "$FRONTEND_OUT" + rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/" + else + sudo mkdir -p "$FRONTEND_OUT" + sudo rsync -a --delete "$REPO_ROOT/frontend/dist/" "$FRONTEND_OUT/" + fi + ok "Frontend deployed to $FRONTEND_OUT" +fi + +# ─── backend build + up ─────────────────────────────────────────────────── +if ! $NO_BUILD; then + log "docker compose build" + docker compose -p "$COMPOSE_PROJECT" build +fi +log "docker compose up -d" +docker compose -p "$COMPOSE_PROJECT" --env-file "$ENV_FILE" up -d + +# ─── health poll ────────────────────────────────────────────────────────── +log "Waiting for /api/health on http://127.0.0.1:${PORT} (up to 60s)…" +healthy=false +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1; then + healthy=true; break + fi + sleep 2 +done +if $healthy; then + ok "Backend healthy." +else + err "Backend did not become healthy. Logs:" + docker compose -p "$COMPOSE_PROJECT" logs --tail=80 backend || true + exit 5 +fi + +# ─── final report ───────────────────────────────────────────────────────── +echo +ok "Deploy complete." +echo +echo " URL : https://optical-dev.oliver.solutions${URL_PATH}/" +echo " API health : https://optical-dev.oliver.solutions${URL_PATH}/api/health" +echo " Backend : 127.0.0.1:${PORT}" +echo " Frontend : ${FRONTEND_OUT}" +echo + +# Detect whether the Include line is already in the vhost; warn if not. +VHOST=/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf +if [[ -r "$VHOST" ]] && grep -qF "apache-${SLUG}.conf" "$VHOST"; then + ok "Apache vhost already Includes ${APACHE_CONF}." +else + warn "First-time deploy: add this line INSIDE of $VHOST :" + echo + echo " Include /opt/${SLUG}/deploy/apache-${SLUG}.conf" + echo + warn "Then: sudo apachectl configtest && sudo systemctl reload apache2" +fi + +if $TAIL_LOGS; then + docker compose -p "$COMPOSE_PROJECT" logs -f --tail=50 backend +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..03ccab9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +# Pinned project name — required by global Docker policy so this stack +# can't collide with another /opt//docker-compose.yml that also +# defaults to the parent directory name. +name: utilisation-dept + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + image: utilisation-dept-backend:local + restart: unless-stopped + # Bind to localhost only — Apache fronts the public traffic. + ports: + - "127.0.0.1:${UTILISATION_DEPT_PORT:-8200}:8000" + # Secrets (especially the bcrypt hash with its $ characters) pass through + # verbatim via env_file, bypassing compose's ${...} interpolation. + env_file: + - .env + environment: + - APP_BASE_PATH=/utilisation-dept + volumes: + # Persist the auth log across container rebuilds — replaces DB audit trail. + - ./backend/logs:/app/logs + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/api/health', timeout=2).status==200 else 1)"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 0000000..4d5641c --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,24 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs', 'vite.config.ts', 'postcss.config.js', 'tailwind.config.js'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + settings: { react: { version: '18.3' } }, + plugins: ['react', 'react-hooks', '@typescript-eslint'], + rules: { + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + }, +}; diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..41d5de4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + L'Oréal Utilisation Dashboard + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e185871 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6727 @@ +{ + "name": "utilisation-dept-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "utilisation-dept-frontend", + "version": "0.1.0", + "dependencies": { + "@headlessui/react": "^2.0.0", + "driver.js": "^1.3.0", + "lucide-react": "^0.400.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.22.0", + "recharts": "^2.12.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0", + "vite": "^5.3.0" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.28", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz", + "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@headlessui/react": { + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.26.16", + "@react-aria/focus": "^3.20.2", + "@react-aria/interactions": "^3.25.0", + "@tanstack/react-virtual": "^3.13.9", + "use-sync-external-store": "^1.5.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@internationalized/date": { + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.1.tgz", + "integrity": "sha512-6IedsVWXyq4P9Tj+TxuU8WGWM70hYLl12nbYU8jkikVpa6WXapFazPUcHUMDMoWftIDE2ILDkFFte6W2nFCkRQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/number": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@internationalized/number/-/number-3.6.6.tgz", + "integrity": "sha512-iFgmQaXHE0vytNfpLZWOC2mEJCBRzcUxt53Xf/yCXG93lRvqas237i3r7X4RKMwO3txiyZD4mQjKAByFv6UGSQ==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@internationalized/string": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.8.tgz", + "integrity": "sha512-NdbMQUSfXLYIQol5VyMtinm9pZDciiMfN7RtmSuSB78io1hqwJ0naYfxyW6vgxWBkzWymQa/3uLDlbfmshtCaA==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-aria/focus": { + "version": "3.22.0", + "resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.22.0.tgz", + "integrity": "sha512-ZfDOVuVhqDsM9mkNji3QUZ/d40JhlVgXrDkrfXylM1035QCrcTHN7m2DpbE95sU2A8EQb4wikvt5jM6K/73BPg==", + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-aria/interactions": { + "version": "3.28.0", + "resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.28.0.tgz", + "integrity": "sha512-OXwdU1EWFdMxmr/K1CXNGJzmNlCClByb+PuCaqUyzBymHPCGVhawirLIon/CrIN5psh3AiWpHSh4H0WeJdVpng==", + "license": "Apache-2.0", + "dependencies": { + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "react-aria": "3.48.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@react-types/shared": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.34.0.tgz", + "integrity": "sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/helpers": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz", + "integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz", + "integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", + "integrity": "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/type-utils": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.18.0.tgz", + "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz", + "integrity": "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz", + "integrity": "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.18.0", + "@typescript-eslint/utils": "7.18.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.18.0.tgz", + "integrity": "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz", + "integrity": "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/visitor-keys": "7.18.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.18.0.tgz", + "integrity": "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.18.0", + "@typescript-eslint/types": "7.18.0", + "@typescript-eslint/typescript-estree": "7.18.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz", + "integrity": "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.18.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.30", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.30.tgz", + "integrity": "sha512-xjOFN16Ha1+Rz4nFYKqHU/LSB+gx/Vi3yQLX7r7sAW+Wa+8hhF2h4pvqTrTMc8+WcDBEunnUurr46Jvv0jk3Vg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/driver.js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz", + "integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.357", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.357.tgz", + "integrity": "sha512-NHlTIQDK8fmVwHwuIzmXYEJ1Ewq3D9wDNc0cWXxDGysP6Pb21giwGNkxiTifyKy/4SoPuN5l6GLP1W9Sv7zB2g==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", + "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.400.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz", + "integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.44", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz", + "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-aria": { + "version": "3.48.0", + "resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.48.0.tgz", + "integrity": "sha512-jQjd4rBEIMqecBaAKYJbVGK6EqIHLa5znVQ7jwFyK5vCyljoj6KhgtiahmcIPsG5vG5vEDLw+ba+bEWn6A2P4w==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "aria-hidden": "^1.2.3", + "clsx": "^2.0.0", + "react-stately": "3.46.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1", + "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-stately": { + "version": "3.46.0", + "resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.46.0.tgz", + "integrity": "sha512-OdxhWvHgs2L4OJGIs7hnuTr5WjjMM6enhNEAMRqiekhF8+ITvA2LRwNftOZwcogaoCslGYq5S2VQTQwnm0GbCA==", + "license": "Apache-2.0", + "dependencies": { + "@internationalized/date": "^3.12.1", + "@internationalized/number": "^3.6.6", + "@internationalized/string": "^3.2.8", + "@react-types/shared": "^3.34.0", + "@swc/helpers": "^0.5.0", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.7", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", + "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.2", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..4f18a6f --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,37 @@ +{ + "name": "utilisation-dept-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@headlessui/react": "^2.0.0", + "driver.js": "^1.3.0", + "lucide-react": "^0.400.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-router-dom": "^6.22.0", + "recharts": "^2.12.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.16.0", + "@typescript-eslint/parser": "^7.16.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.0", + "eslint": "^8.57.0", + "eslint-plugin-react": "^7.34.0", + "eslint-plugin-react-hooks": "^4.6.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.5.0", + "vite": "^5.3.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..ed13c3c --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,65 @@ +import { lazy, Suspense } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import AuthGate from './components/AuthGate'; +import Navbar from './components/Navbar'; +import Loading from './components/Loading'; +import Login from './pages/Login'; + +const Department = lazy(() => import('./pages/Department')); +const Resourcing = lazy(() => import('./pages/Resourcing')); +const Bookings = lazy(() => import('./pages/Bookings')); +const Tutorial = lazy(() => import('./pages/Tutorial')); + +function ProtectedShell({ children }: { children: React.ReactNode }) { + return ( + +
+ +
+ }>{children} +
+
+
+ ); +} + +export default function App() { + return ( + + } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..803b325 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,54 @@ +const apiUrl = (path: string) => + `${import.meta.env.BASE_URL}api${path.startsWith('/') ? path : '/' + path}`; + +export class ApiError extends Error { + constructor( + public status: number, + public detail: string, + ) { + super(detail); + this.name = 'ApiError'; + } +} + +interface ExtraInit extends RequestInit { + /** Skip the default JSON content-type header — useful for multipart uploads. */ + skipJsonContentType?: boolean; +} + +export async function apiFetch(path: string, init: ExtraInit = {}): Promise { + const { skipJsonContentType, headers, ...rest } = init; + const finalHeaders: Record = { ...(headers as Record | undefined) }; + if (!skipJsonContentType && !('Content-Type' in finalHeaders)) { + finalHeaders['Content-Type'] = 'application/json'; + } + + const res = await fetch(apiUrl(path), { + credentials: 'include', + headers: finalHeaders, + ...rest, + }); + + if (res.status === 401 && !path.includes('/auth/')) { + window.location.href = `${import.meta.env.BASE_URL}login`; + throw new ApiError(401, 'Unauthorised'); + } + + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new ApiError(res.status, detail); + } + + if (res.status === 204) return undefined as T; + return res.json() as Promise; +} + +export function buildQuery(params: Record): string { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === null || v === undefined || v === '') continue; + usp.append(k, String(v)); + } + const qs = usp.toString(); + return qs ? `?${qs}` : ''; +} diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts new file mode 100644 index 0000000..b1af91e --- /dev/null +++ b/frontend/src/api/endpoints.ts @@ -0,0 +1,71 @@ +import { apiFetch, buildQuery } from './client'; +import type { + AuthMeResponse, + BookingsResponse, + LoginResponse, + MetaResponse, + ParseResponse, + ResourcesResponse, + UtilisationSummaryResponse, +} from './types'; + +// ---- Auth ----------------------------------------------------------------- + +export function login(username: string, password: string) { + return apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify({ username, password }), + }); +} + +export function logout() { + return apiFetch('/auth/logout', { method: 'POST' }); +} + +export function getMe() { + return apiFetch('/auth/me'); +} + +// ---- Airtable ------------------------------------------------------------- + +export function getResources(includeInactive = false) { + return apiFetch(`/airtable/resources${buildQuery({ include_inactive: includeInactive })}`); +} + +export function getBookings(params: { from?: string; to?: string; refresh?: boolean } = {}) { + return apiFetch(`/airtable/bookings${buildQuery(params)}`); +} + +export function getMeta() { + return apiFetch('/airtable/meta'); +} + +// ---- Timelog -------------------------------------------------------------- + +export function parseTimelog(file: File) { + const fd = new FormData(); + fd.append('file', file); + return apiFetch('/timelog/parse', { + method: 'POST', + body: fd, + skipJsonContentType: true, + }); +} + +// ---- Utilisation ---------------------------------------------------------- + +export interface SummaryParams { + from?: string; + to?: string; + department?: string; + name?: string; + billing_type?: string; + timelogHash?: string; +} + +export function getUtilisationSummary(params: SummaryParams = {}) { + const { timelogHash, ...query } = params; + const headers: Record = {}; + if (timelogHash) headers['X-Timelog-Hash'] = timelogHash; + return apiFetch(`/utilisation/summary${buildQuery(query)}`, { headers }); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..5af4171 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,92 @@ +export interface Resource { + recordId: string; + name: string; + email: string; + department: string; + roles: string[]; + inactive: boolean; + availHoursPerWeek: number; + startDate: string | null; + endDate: string | null; + employmentType: 'FTE' | 'Freelancer' | string; + country: string | null; +} + +export interface Booking { + id: string; + task: string; + startDate: string; + endDate: string; + resourceName: string; + projectNumber: string; + projectName: string; + department: string; + division: string; + hoursSelection: string[]; + totalHoursBooked: number; + bookingStatus: string; + placeholder: boolean; +} + +export interface MetaResponse { + departments: string[]; + billingTypes: string[]; + employmentTypes: string[]; + bookingStatuses: string[]; +} + +export interface TimelogRow { + date: string; + employee: string; + project: string; + task: string; + hours: number; + billable: boolean; +} + +export interface ParseResponse { + rows: TimelogRow[]; + unrecognised_columns: string[]; + content_hash: string; +} + +export interface UtilisationSummaryRow { + period: string; + employee: string; + department: string; + employmentType: string; + availableHours: number; + bookedHours: number; + loggedHours: number; + billableHours: number; + nonBillableHours: number; + forecastHours: number; + actualUtilisationPct: number; + bookedUtilisationPct: number; +} + +export interface UtilisationSummaryResponse { + rows: UtilisationSummaryRow[]; + filters_applied: Record; +} + +export interface ResourcesResponse { + resources: Resource[]; + cached_at: string; +} + +export interface BookingsResponse { + bookings: Booking[]; + cached_at: string; +} + +export interface AuthMeResponse { + username: string; + mode: string; +} + +export interface LoginResponse { + ok: boolean; + username: string; + mode: string; +} diff --git a/frontend/src/components/AuthGate.tsx b/frontend/src/components/AuthGate.tsx new file mode 100644 index 0000000..ca0b888 --- /dev/null +++ b/frontend/src/components/AuthGate.tsx @@ -0,0 +1,12 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../hooks/useAuth'; +import Loading from './Loading'; + +export default function AuthGate({ children }: { children: React.ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + + if (loading) return ; + if (!user) return ; + return <>{children}; +} diff --git a/frontend/src/components/ErrorBox.tsx b/frontend/src/components/ErrorBox.tsx new file mode 100644 index 0000000..0324ee6 --- /dev/null +++ b/frontend/src/components/ErrorBox.tsx @@ -0,0 +1,26 @@ +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +interface Props { + message: string; + onRetry?: () => void; +} + +export default function ErrorBox({ message, onRetry }: Props) { + return ( +
+ +
+
Something went wrong
+
{message}
+
+ {onRetry && ( + + )} +
+ ); +} diff --git a/frontend/src/components/FilterBar.tsx b/frontend/src/components/FilterBar.tsx new file mode 100644 index 0000000..e1fb5fa --- /dev/null +++ b/frontend/src/components/FilterBar.tsx @@ -0,0 +1,163 @@ +import { useMemo } from 'react'; +import { RotateCcw } from 'lucide-react'; +import type { Dispatch } from 'react'; +import type { FilterAction, FilterState } from '../lib/filters'; +import { PRESET_LABELS, type DatePreset } from '../lib/dates'; + +const PRESETS: DatePreset[] = ['this-week', 'last-week', 'this-month', 'last-month', 'custom']; + +interface Props { + state: FilterState; + dispatch: Dispatch; + departments: string[]; + names: string[]; + billingTypes: string[]; + showForecastToggle?: boolean; +} + +function MultiSelect({ + label, + options, + selected, + onChange, + tutorialId, +}: { + label: string; + options: string[]; + selected: string[]; + onChange: (next: string[]) => void; + tutorialId?: string; +}) { + return ( +
+ + +
+ ); +} + +export default function FilterBar({ + state, + dispatch, + departments, + names, + billingTypes, + showForecastToggle = true, +}: Props) { + const customRange = useMemo( + () => state.range ?? { from: '', to: '' }, + [state.range], + ); + + return ( +
+
+
+ + +
+ +
+ + + dispatch({ type: 'set-custom-range', range: { from: e.target.value, to: customRange.to } }) + } + className="input" + /> +
+
+ + + dispatch({ type: 'set-custom-range', range: { from: customRange.from, to: e.target.value } }) + } + className="input" + /> +
+ +
+ {showForecastToggle && ( + + )} + + +
+
+ +
+ dispatch({ type: 'set-departments', departments: v })} + tutorialId="filter-department" + /> + dispatch({ type: 'set-names', names: v })} + tutorialId="filter-name" + /> +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Loading.tsx b/frontend/src/components/Loading.tsx new file mode 100644 index 0000000..ab12ee2 --- /dev/null +++ b/frontend/src/components/Loading.tsx @@ -0,0 +1,10 @@ +import { Loader2 } from 'lucide-react'; + +export default function Loading({ label = 'Loading…' }: { label?: string }) { + return ( +
+ + {label} +
+ ); +} diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..40d3a0a --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,60 @@ +import { NavLink, useNavigate } from 'react-router-dom'; +import { LogOut, BarChart3 } from 'lucide-react'; +import { useAuth } from '../hooks/useAuth'; + +const tabs = [ + { to: '/', label: 'Department', end: true }, + { to: '/resourcing', label: 'Resourcing' }, + { to: '/bookings', label: 'Bookings' }, + { to: '/tutorial', label: 'Tutorial' }, +]; + +export default function Navbar() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate('/login', { replace: true }); + }; + + return ( +
+
+
+ + Utilisation +
+ +
+ {user && Signed in as {user.username}} + +
+
+
+ ); +} diff --git a/frontend/src/components/UploadButton.tsx b/frontend/src/components/UploadButton.tsx new file mode 100644 index 0000000..dbf382d --- /dev/null +++ b/frontend/src/components/UploadButton.tsx @@ -0,0 +1,91 @@ +import { useRef, useState } from 'react'; +import { Upload, FileSpreadsheet, X } from 'lucide-react'; + +const ACCEPT = '.xlsx,.csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv'; + +export interface UploadButtonProps { + uploading: boolean; + error: string | null; + unrecognised: string[]; + filename: string | null; + rowCount: number; + onFile: (file: File) => void; + onClear: () => void; +} + +export default function UploadButton({ + uploading, + error, + unrecognised, + filename, + rowCount, + onFile, + onClear, +}: UploadButtonProps) { + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + const onChange = (e: React.ChangeEvent) => { + const f = e.target.files?.[0]; + if (f) onFile(f); + e.target.value = ''; + }; + + const onDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const f = e.dataTransfer.files?.[0]; + if (f) onFile(f); + }; + + return ( +
+
{ + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={onDrop} + className={[ + 'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition', + dragOver ? 'border-blue-400 bg-blue-50' : 'border-slate-300 bg-white', + ].join(' ')} + > + +

+ Drag & drop a timelog file (.xlsx or .csv), or +

+ + + {filename && !error && ( +
+ + {filename} — {rowCount} row{rowCount === 1 ? '' : 's'} + + +
+ )} +
+ + {error &&
{error}
} + + {unrecognised.length > 0 && ( +
+ Unrecognised columns: {unrecognised.join(', ')} +
+ )} +
+ ); +} diff --git a/frontend/src/components/charts/BillabilityBreakdown.tsx b/frontend/src/components/charts/BillabilityBreakdown.tsx new file mode 100644 index 0000000..ff2046e --- /dev/null +++ b/frontend/src/components/charts/BillabilityBreakdown.tsx @@ -0,0 +1,69 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UtilisationSummaryRow } from '../../api/types'; + +interface Props { + rows: UtilisationSummaryRow[]; +} + +interface Bucket { + employee: string; + billable: number; + nonBillable: number; + leave: number; + idle: number; +} + +function aggregate(rows: UtilisationSummaryRow[]): Bucket[] { + const map = new Map(); + for (const r of rows) { + const b = map.get(r.employee) ?? { + employee: r.employee, + billable: 0, + nonBillable: 0, + leave: 0, + idle: 0, + }; + b.billable += r.billableHours; + b.nonBillable += r.nonBillableHours; + // Leave & idle are not split out by the API yet; we infer "idle" as available - logged. + const accountedLogged = r.billableHours + r.nonBillableHours; + const slack = Math.max(0, r.availableHours - accountedLogged); + b.idle += slack; + map.set(r.employee, b); + } + return [...map.values()].sort((a, b) => a.employee.localeCompare(b.employee)); +} + +export default function BillabilityBreakdown({ rows }: Props) { + const data = aggregate(rows); + + return ( +
+

Billability Breakdown

+
+ + + + + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/charts/BookingVsActual.tsx b/frontend/src/components/charts/BookingVsActual.tsx new file mode 100644 index 0000000..dbca874 --- /dev/null +++ b/frontend/src/components/charts/BookingVsActual.tsx @@ -0,0 +1,55 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UtilisationSummaryRow } from '../../api/types'; + +interface Props { + rows: UtilisationSummaryRow[]; +} + +interface Bucket { + employee: string; + booked: number; + actual: number; +} + +function aggregateByEmployee(rows: UtilisationSummaryRow[]): Bucket[] { + const map = new Map(); + for (const r of rows) { + const b = map.get(r.employee) ?? { employee: r.employee, booked: 0, actual: 0 }; + b.booked += r.bookedHours; + b.actual += r.loggedHours; + map.set(r.employee, b); + } + return [...map.values()].sort((a, b) => b.booked + b.actual - (a.booked + a.actual)); +} + +export default function BookingVsActual({ rows }: Props) { + const data = aggregateByEmployee(rows); + + return ( +
+

Booking vs Actual

+
+ + + + + + + + + + + +
+
+ ); +} diff --git a/frontend/src/components/charts/FTEvsFreelancer.tsx b/frontend/src/components/charts/FTEvsFreelancer.tsx new file mode 100644 index 0000000..29ec84c --- /dev/null +++ b/frontend/src/components/charts/FTEvsFreelancer.tsx @@ -0,0 +1,71 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UtilisationSummaryRow } from '../../api/types'; + +interface Props { + rows: UtilisationSummaryRow[]; +} + +interface Bucket { + employee: string; + utilisation: number; +} + +function split(rows: UtilisationSummaryRow[]): { fte: Bucket[]; freelancer: Bucket[] } { + const fteMap = new Map(); + const freelancerMap = new Map(); + for (const r of rows) { + const target = r.employmentType === 'FTE' ? fteMap : r.employmentType === 'Freelancer' ? freelancerMap : null; + if (!target) continue; + const b = target.get(r.employee) ?? { available: 0, booked: 0 }; + b.available += r.availableHours; + b.booked += r.bookedHours; + target.set(r.employee, b); + } + const toBuckets = (m: Map): Bucket[] => + [...m.entries()] + .map(([employee, v]) => ({ + employee, + utilisation: v.available > 0 ? Math.round((v.booked / v.available) * 1000) / 10 : 0, + })) + .sort((a, b) => b.utilisation - a.utilisation); + return { fte: toBuckets(fteMap), freelancer: toBuckets(freelancerMap) }; +} + +function MiniChart({ title, data, fill }: { title: string; data: Bucket[]; fill: string }) { + return ( +
+

{title}

+
+ + + + + + `${v}%`} /> + + + + +
+
+ ); +} + +export default function FTEvsFreelancer({ rows }: Props) { + const { fte, freelancer } = split(rows); + return ( +
+ + +
+ ); +} diff --git a/frontend/src/components/charts/MonthlyUtilisation.tsx b/frontend/src/components/charts/MonthlyUtilisation.tsx new file mode 100644 index 0000000..51c1294 --- /dev/null +++ b/frontend/src/components/charts/MonthlyUtilisation.tsx @@ -0,0 +1,70 @@ +import { + Bar, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UtilisationSummaryRow } from '../../api/types'; +import { formatPeriod, monthOf } from '../../lib/dates'; + +interface Props { + rows: UtilisationSummaryRow[]; + showForecast: boolean; +} + +interface Bucket { + period: string; + available: number; + booked: number; + logged: number; + forecast: number; +} + +function aggregateByMonth(rows: UtilisationSummaryRow[]): Bucket[] { + const map = new Map(); + for (const r of rows) { + const key = /^\d{4}-\d{2}/.test(r.period) ? r.period.slice(0, 7) : monthOf(r.period); + const b = map.get(key) ?? { period: key, available: 0, booked: 0, logged: 0, forecast: 0 }; + b.available += r.availableHours; + b.booked += r.bookedHours; + b.logged += r.loggedHours; + b.forecast += r.forecastHours; + map.set(key, b); + } + return [...map.values()].sort((a, b) => a.period.localeCompare(b.period)); +} + +export default function MonthlyUtilisation({ rows, showForecast }: Props) { + const data = aggregateByMonth(rows).map((b) => ({ + ...b, + label: formatPeriod(b.period), + })); + + return ( +
+

Monthly Utilisation

+
+ + + + + + + + + + + {showForecast && ( + + )} + + +
+
+ ); +} diff --git a/frontend/src/components/charts/ProjectLoadPerPerson.tsx b/frontend/src/components/charts/ProjectLoadPerPerson.tsx new file mode 100644 index 0000000..2a7e6dc --- /dev/null +++ b/frontend/src/components/charts/ProjectLoadPerPerson.tsx @@ -0,0 +1,59 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { Booking } from '../../api/types'; + +interface Props { + bookings: Booking[]; +} + +const PALETTE = ['#2563eb', '#10b981', '#f59e0b', '#a855f7', '#ef4444', '#0ea5e9', '#84cc16', '#f97316']; + +interface Row { + employee: string; + [project: string]: number | string; +} + +function aggregate(bookings: Booking[]): { data: Row[]; projects: string[] } { + const projects = new Set(); + const map = new Map(); + for (const bk of bookings) { + projects.add(bk.projectName || bk.projectNumber || 'Unknown'); + const projectKey = bk.projectName || bk.projectNumber || 'Unknown'; + const row = map.get(bk.resourceName) ?? { employee: bk.resourceName }; + row[projectKey] = ((row[projectKey] as number) ?? 0) + bk.totalHoursBooked; + map.set(bk.resourceName, row); + } + return { data: [...map.values()].sort((a, b) => a.employee.localeCompare(b.employee)), projects: [...projects] }; +} + +export default function ProjectLoadPerPerson({ bookings }: Props) { + const { data, projects } = aggregate(bookings); + + return ( +
+

Project Load per Person

+
+ + + + + + + + {projects.map((p, i) => ( + + ))} + + +
+
+ ); +} diff --git a/frontend/src/components/charts/WeeklyUtilisation.tsx b/frontend/src/components/charts/WeeklyUtilisation.tsx new file mode 100644 index 0000000..0fd6fbc --- /dev/null +++ b/frontend/src/components/charts/WeeklyUtilisation.tsx @@ -0,0 +1,69 @@ +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { UtilisationSummaryRow } from '../../api/types'; +import { formatPeriod, weekOf } from '../../lib/dates'; + +interface Props { + rows: UtilisationSummaryRow[]; + onPeriodClick?: (period: string) => void; +} + +interface Bucket { + period: string; + booked: number; + logged: number; + available: number; +} + +function aggregateByWeek(rows: UtilisationSummaryRow[]): Bucket[] { + const map = new Map(); + for (const r of rows) { + const key = /^\d{4}-W\d{2}$/.test(r.period) ? r.period : weekOf(r.period); + const b = map.get(key) ?? { period: key, booked: 0, logged: 0, available: 0 }; + b.booked += r.bookedHours; + b.logged += r.loggedHours; + b.available += r.availableHours; + map.set(key, b); + } + return [...map.values()].sort((a, b) => a.period.localeCompare(b.period)); +} + +export default function WeeklyUtilisation({ rows, onPeriodClick }: Props) { + const data = aggregateByWeek(rows).map((b) => ({ ...b, label: formatPeriod(b.period) })); + + return ( +
+

Weekly Utilisation

+
+ + + + + + + + { + if (onPeriodClick && payload?.period) onPeriodClick(payload.period); + }} + style={{ cursor: onPeriodClick ? 'pointer' : 'default' }} + /> + + + + +
+
+ ); +} diff --git a/frontend/src/components/tutorial/TutorialOverlay.tsx b/frontend/src/components/tutorial/TutorialOverlay.tsx new file mode 100644 index 0000000..06f43ad --- /dev/null +++ b/frontend/src/components/tutorial/TutorialOverlay.tsx @@ -0,0 +1,41 @@ +import { useEffect } from 'react'; +import { driver, type Config } from 'driver.js'; +import 'driver.js/dist/driver.css'; +import { allSteps, type TutorialSection } from './steps'; + +interface Props { + section: TutorialSection; + onClose?: () => void; +} + +/** Runs a driver.js tour for the selected section. Renders nothing — drives the DOM. */ +export default function TutorialOverlay({ section, onClose }: Props) { + useEffect(() => { + const steps = allSteps[section] + .map((s) => ({ + element: `[data-tutorial-id="${s.selector}"]`, + popover: { title: s.title, description: s.description }, + })) + // Only include steps whose element actually exists on this page. + .filter((s) => document.querySelector(s.element)); + + if (steps.length === 0) { + onClose?.(); + return; + } + + const config: Config = { + showProgress: true, + steps, + onDestroyed: () => onClose?.(), + }; + const d = driver(config); + d.drive(); + + return () => { + d.destroy(); + }; + }, [section, onClose]); + + return null; +} diff --git a/frontend/src/components/tutorial/steps.ts b/frontend/src/components/tutorial/steps.ts new file mode 100644 index 0000000..17b18f5 --- /dev/null +++ b/frontend/src/components/tutorial/steps.ts @@ -0,0 +1,78 @@ +export interface TutorialStep { + /** matches the `data-tutorial-id` attribute on the target element */ + selector: string; + title: string; + description: string; +} + +export const departmentSteps: TutorialStep[] = [ + { + selector: 'navbar', + title: 'Top navigation', + description: 'Switch between Department, Resourcing, Bookings and Tutorial tabs from here.', + }, + { + selector: 'upload-zone', + title: 'Upload your timelog', + description: 'Drag a Harvest/Toggl export here, or click to choose a file. .xlsx and .csv supported.', + }, + { + selector: 'filter-bar', + title: 'Filter the view', + description: 'Pick a date range preset (or use a custom range), and narrow down by department, name or billing type.', + }, + { + selector: 'forecast-toggle', + title: 'Forecast line', + description: 'Toggle the forecast overlay on or off — useful for spotting upcoming over- or under-bookings.', + }, + { + selector: 'chart-monthly-utilisation', + title: 'Monthly utilisation', + description: 'Booked, logged and available hours grouped by calendar month.', + }, + { + selector: 'chart-booking-vs-actual', + title: 'Booking vs Actual', + description: 'See whose bookings match their logged hours and whose drift.', + }, +]; + +export const resourcingSteps: TutorialStep[] = [ + { + selector: 'chart-weekly-utilisation', + title: 'Weekly utilisation', + description: 'Click a bar to drill into a specific week.', + }, + { + selector: 'chart-project-load', + title: 'Project load per person', + description: 'Stacked bars show which projects are loading up each resource.', + }, + { + selector: 'chart-fte-vs-freelancer', + title: 'FTE vs Freelancer', + description: 'Compare utilisation between salaried staff and contractors.', + }, +]; + +export const bookingsSteps: TutorialStep[] = [ + { + selector: 'bookings-table', + title: 'Bookings table', + description: 'A virtualised view of every booking returned by Airtable for the active filters.', + }, + { + selector: 'bookings-refresh', + title: 'Refresh from Airtable', + description: 'Bypass the cache and pull a fresh copy from Airtable. Use sparingly — counts against API rate limits.', + }, +]; + +export const allSteps = { + department: departmentSteps, + resourcing: resourcingSteps, + bookings: bookingsSteps, +} as const; + +export type TutorialSection = keyof typeof allSteps; diff --git a/frontend/src/hooks/useAirtableData.ts b/frontend/src/hooks/useAirtableData.ts new file mode 100644 index 0000000..5851ad2 --- /dev/null +++ b/frontend/src/hooks/useAirtableData.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useState } from 'react'; +import * as api from '../api/endpoints'; +import type { MetaResponse, Resource } from '../api/types'; +import { ApiError } from '../api/client'; + +interface State { + resources: Resource[]; + meta: MetaResponse | null; + cachedAt: string | null; + loading: boolean; + error: string | null; +} + +const initial: State = { + resources: [], + meta: null, + cachedAt: null, + loading: true, + error: null, +}; + +export function useAirtableData(includeInactive = false) { + const [state, setState] = useState(initial); + + const load = useCallback(async () => { + setState((s) => ({ ...s, loading: true, error: null })); + try { + const [res, meta] = await Promise.all([api.getResources(includeInactive), api.getMeta()]); + setState({ + resources: res.resources, + meta, + cachedAt: res.cached_at, + loading: false, + error: null, + }); + } catch (err) { + const message = err instanceof ApiError ? err.detail : (err as Error).message; + setState((s) => ({ ...s, loading: false, error: message })); + } + }, [includeInactive]); + + useEffect(() => { + void load(); + }, [load]); + + return { ...state, refresh: load }; +} diff --git a/frontend/src/hooks/useAuth.tsx b/frontend/src/hooks/useAuth.tsx new file mode 100644 index 0000000..2cc31a3 --- /dev/null +++ b/frontend/src/hooks/useAuth.tsx @@ -0,0 +1,69 @@ +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import * as api from '../api/endpoints'; +import { ApiError } from '../api/client'; + +export interface AuthUser { + username: string; + mode: string; +} + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + login: (username: string, password: string) => Promise; + logout: () => Promise; + refresh: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + const refresh = useCallback(async () => { + try { + const me = await api.getMe(); + setUser({ username: me.username, mode: me.mode }); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + setUser(null); + } else { + // Network or server error — treat as logged-out, surface elsewhere. + setUser(null); + } + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const login = useCallback(async (username: string, password: string) => { + const res = await api.login(username, password); + setUser({ username: res.username, mode: res.mode }); + }, []); + + const logout = useCallback(async () => { + try { + await api.logout(); + } finally { + setUser(null); + } + }, []); + + const value = useMemo( + () => ({ user, loading, login, logout, refresh }), + [user, loading, login, logout, refresh], + ); + + return {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used inside '); + return ctx; +} diff --git a/frontend/src/hooks/useTimelog.ts b/frontend/src/hooks/useTimelog.ts new file mode 100644 index 0000000..824f95b --- /dev/null +++ b/frontend/src/hooks/useTimelog.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; +import * as api from '../api/endpoints'; +import type { TimelogRow } from '../api/types'; +import { ApiError } from '../api/client'; + +interface TimelogState { + rows: TimelogRow[]; + hash: string | null; + unrecognised: string[]; + filename: string | null; + uploading: boolean; + error: string | null; +} + +const initial: TimelogState = { + rows: [], + hash: null, + unrecognised: [], + filename: null, + uploading: false, + error: null, +}; + +export function useTimelog() { + const [state, setState] = useState(initial); + + const upload = useCallback(async (file: File) => { + setState((s) => ({ ...s, uploading: true, error: null })); + try { + const res = await api.parseTimelog(file); + setState({ + rows: res.rows, + hash: res.content_hash, + unrecognised: res.unrecognised_columns, + filename: file.name, + uploading: false, + error: null, + }); + } catch (err) { + const message = err instanceof ApiError ? err.detail : (err as Error).message; + setState((s) => ({ ...s, uploading: false, error: message })); + } + }, []); + + const clear = useCallback(() => setState(initial), []); + + return { ...state, upload, clear }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..8fe3258 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,41 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + color-scheme: light; + } + + html, + body, + #root { + height: 100%; + } + + body { + @apply bg-slate-50 text-slate-900 antialiased; + font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + } +} + +@layer components { + .btn { + @apply inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium shadow-sm transition; + } + .btn-primary { + @apply btn bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50; + } + .btn-secondary { + @apply btn bg-white text-slate-700 ring-1 ring-slate-300 hover:bg-slate-50; + } + .card { + @apply rounded-lg bg-white p-4 shadow-sm ring-1 ring-slate-200; + } + .input { + @apply w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500; + } + .label { + @apply mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500; + } +} diff --git a/frontend/src/lib/csv.ts b/frontend/src/lib/csv.ts new file mode 100644 index 0000000..66b5d46 --- /dev/null +++ b/frontend/src/lib/csv.ts @@ -0,0 +1,37 @@ +// UTF-8 BOM so Excel opens the file with proper encoding. +const BOM = ''; + +/** Escape a single CSV cell. Cells starting with =,+,-,@ are prefixed with a single + * quote to defang spreadsheet formula injection (CWE-1236). Cells containing + * quotes, commas or newlines are double-quoted with internal quotes doubled. */ +function escapeCell(value: unknown): string { + if (value === null || value === undefined) return ''; + let cell = String(value); + if (/^[=+\-@]/.test(cell)) cell = `'${cell}`; + if (/["\n\r,]/.test(cell)) { + cell = `"${cell.replace(/"/g, '""')}"`; + } + return cell; +} + +export function rowsToCsv>(rows: T[], headers?: string[]): string { + if (rows.length === 0 && !headers) return BOM; + const cols = headers ?? Object.keys(rows[0]); + const out = [cols.map(escapeCell).join(',')]; + for (const row of rows) { + out.push(cols.map((c) => escapeCell(row[c])).join(',')); + } + return BOM + out.join('\r\n'); +} + +export function downloadCsv(filename: string, csv: string): void { + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/frontend/src/lib/dates.ts b/frontend/src/lib/dates.ts new file mode 100644 index 0000000..702d58a --- /dev/null +++ b/frontend/src/lib/dates.ts @@ -0,0 +1,107 @@ +export type DatePreset = 'this-week' | 'last-week' | 'this-month' | 'last-month' | 'custom'; + +export const PRESET_LABELS: Record = { + 'this-week': 'This Week', + 'last-week': 'Last Week', + 'this-month': 'This Month', + 'last-month': 'Last Month', + custom: 'Custom', +}; + +export function toIsoDate(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +function startOfWeekMonday(d: Date): Date { + const out = new Date(d); + out.setHours(0, 0, 0, 0); + const day = out.getDay(); // 0 Sun … 6 Sat + const diff = (day + 6) % 7; // days since Monday + out.setDate(out.getDate() - diff); + return out; +} + +function endOfWeekSunday(d: Date): Date { + const start = startOfWeekMonday(d); + start.setDate(start.getDate() + 6); + return start; +} + +function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +function endOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth() + 1, 0); +} + +export interface DateRange { + from: string; + to: string; +} + +export function dateRangePreset(preset: DatePreset, today: Date = new Date()): DateRange | null { + switch (preset) { + case 'this-week': + return { from: toIsoDate(startOfWeekMonday(today)), to: toIsoDate(endOfWeekSunday(today)) }; + case 'last-week': { + const lw = new Date(today); + lw.setDate(lw.getDate() - 7); + return { from: toIsoDate(startOfWeekMonday(lw)), to: toIsoDate(endOfWeekSunday(lw)) }; + } + case 'this-month': + return { from: toIsoDate(startOfMonth(today)), to: toIsoDate(endOfMonth(today)) }; + case 'last-month': { + const lm = new Date(today.getFullYear(), today.getMonth() - 1, 15); + return { from: toIsoDate(startOfMonth(lm)), to: toIsoDate(endOfMonth(lm)) }; + } + case 'custom': + default: + return null; + } +} + +/** Returns YYYY-Www, e.g. 2026-W20 — ISO week of the supplied date string. */ +export function weekOf(iso: string): string { + const d = new Date(`${iso}T00:00:00`); + // ISO week algorithm. + const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); + const day = (target.getUTCDay() + 6) % 7; + target.setUTCDate(target.getUTCDate() - day + 3); + const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); + const firstDay = (firstThursday.getUTCDay() + 6) % 7; + firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDay + 3); + const weekNum = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 86400000)); + return `${target.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; +} + +/** Returns YYYY-MM. */ +export function monthOf(iso: string): string { + return iso.slice(0, 7); +} + +const monthFormatter = new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' }); + +export function formatPeriod(period: string): string { + if (/^\d{4}-W\d{2}$/.test(period)) return period.replace('-W', ' W'); + if (/^\d{4}-\d{2}$/.test(period)) { + const [y, m] = period.split('-').map(Number); + return monthFormatter.format(new Date(y, m - 1, 1)); + } + return period; +} + +const numberFormatter = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1 }); +const percentFormatter = new Intl.NumberFormat('en-GB', { maximumFractionDigits: 1, style: 'percent' }); + +export function formatHours(n: number): string { + return `${numberFormatter.format(n)}h`; +} + +export function formatPercent(fraction: number): string { + // Inputs are in the 0–1 range. UtilisationSummaryRow uses pct = 0..100 so caller must divide. + return percentFormatter.format(fraction); +} diff --git a/frontend/src/lib/filters.ts b/frontend/src/lib/filters.ts new file mode 100644 index 0000000..b012493 --- /dev/null +++ b/frontend/src/lib/filters.ts @@ -0,0 +1,63 @@ +import type { DatePreset, DateRange } from './dates'; +import { dateRangePreset } from './dates'; + +export interface FilterState { + preset: DatePreset; + range: DateRange | null; + departments: string[]; + names: string[]; + billingType: string | null; + showForecast: boolean; +} + +export const initialFilterState: FilterState = { + preset: 'this-month', + range: dateRangePreset('this-month'), + departments: [], + names: [], + billingType: null, + showForecast: true, +}; + +export type FilterAction = + | { type: 'set-preset'; preset: DatePreset } + | { type: 'set-custom-range'; range: DateRange } + | { type: 'set-departments'; departments: string[] } + | { type: 'set-names'; names: string[] } + | { type: 'set-billing-type'; billingType: string | null } + | { type: 'toggle-forecast' } + | { type: 'reset' }; + +export function filterReducer(state: FilterState, action: FilterAction): FilterState { + switch (action.type) { + case 'set-preset': { + const range = dateRangePreset(action.preset) ?? state.range; + return { ...state, preset: action.preset, range }; + } + case 'set-custom-range': + return { ...state, preset: 'custom', range: action.range }; + case 'set-departments': + return { ...state, departments: action.departments }; + case 'set-names': + return { ...state, names: action.names }; + case 'set-billing-type': + return { ...state, billingType: action.billingType }; + case 'toggle-forecast': + return { ...state, showForecast: !state.showForecast }; + case 'reset': + return { ...initialFilterState, range: dateRangePreset(initialFilterState.preset) }; + default: + return state; + } +} + +/** Pack filter state into the query params the backend expects. */ +export function filtersToQuery(state: FilterState): Record { + return { + from: state.range?.from, + to: state.range?.to, + department: state.departments.length ? state.departments.join(',') : undefined, + name: state.names.length ? state.names.join(',') : undefined, + billing_type: state.billingType ?? undefined, + }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..cc4488c --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import App from './App'; +import { AuthProvider } from './hooks/useAuth'; +import './index.css'; + +const basename = import.meta.env.BASE_URL.replace(/\/$/, ''); + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + + + , +); diff --git a/frontend/src/pages/Bookings.tsx b/frontend/src/pages/Bookings.tsx new file mode 100644 index 0000000..adc3005 --- /dev/null +++ b/frontend/src/pages/Bookings.tsx @@ -0,0 +1,172 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import { Download, RefreshCw } from 'lucide-react'; +import FilterBar from '../components/FilterBar'; +import Loading from '../components/Loading'; +import ErrorBox from '../components/ErrorBox'; +import { useAirtableData } from '../hooks/useAirtableData'; +import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters'; +import { downloadCsv, rowsToCsv } from '../lib/csv'; +import * as api from '../api/endpoints'; +import { ApiError } from '../api/client'; +import type { Booking } from '../api/types'; + +const ROW_HEIGHT = 36; +const OVERSCAN = 8; + +export default function Bookings() { + const airtable = useAirtableData(false); + const [filters, dispatch] = useReducer(filterReducer, initialFilterState); + const [bookings, setBookings] = useState([]); + const [cachedAt, setCachedAt] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportH, setViewportH] = useState(480); + + const names = useMemo( + () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), + [airtable.resources], + ); + + const load = useCallback( + async (refresh = false) => { + setLoading(true); + setError(null); + try { + const q = filtersToQuery(filters); + const res = await api.getBookings({ from: q.from, to: q.to, refresh }); + setBookings(res.bookings); + setCachedAt(res.cached_at); + } catch (err) { + setError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setLoading(false); + } + }, + [filters], + ); + + useEffect(() => { + void load(); + }, [load]); + + const filtered = useMemo(() => { + return bookings.filter((b) => { + if (filters.departments.length && !filters.departments.includes(b.department)) return false; + if (filters.names.length && !filters.names.includes(b.resourceName)) return false; + return true; + }); + }, [bookings, filters.departments, filters.names]); + + const total = filtered.length; + const totalHeight = total * ROW_HEIGHT; + const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN); + const endIndex = Math.min(total, Math.ceil((scrollTop + viewportH) / ROW_HEIGHT) + OVERSCAN); + const visible = filtered.slice(startIndex, endIndex); + + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const update = () => setViewportH(el.clientHeight); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const handleExport = () => { + if (filtered.length === 0) return; + const csv = rowsToCsv(filtered as unknown as Record[]); + downloadCsv(`bookings-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv); + }; + + return ( +
+ + +
+
+ {total.toLocaleString()} booking{total === 1 ? '' : 's'} + {cachedAt && — cached at {new Date(cachedAt).toLocaleString('en-GB')}} +
+
+ + +
+
+ + {error && load(false)} />} + {loading && } + + {!loading && !error && ( +
+
+
Resource
+
Project #
+
Project
+
Task
+
Dates
+
Hours
+
Status
+
+
setScrollTop((e.target as HTMLDivElement).scrollTop)} + style={{ height: 480 }} + className="relative overflow-auto" + > +
+
+ {visible.map((b) => ( +
+
+ {b.resourceName} + {b.placeholder && PLACEHOLDER} +
+
{b.projectNumber}
+
{b.projectName}
+
{b.task}
+
{b.startDate} → {b.endDate}
+
{b.totalHoursBooked.toFixed(1)}
+
{b.bookingStatus}
+
+ ))} + {total === 0 && ( +
No bookings match the current filters.
+ )} +
+
+
+
+ )} +
+ ); +} diff --git a/frontend/src/pages/Department.tsx b/frontend/src/pages/Department.tsx new file mode 100644 index 0000000..9e35a5e --- /dev/null +++ b/frontend/src/pages/Department.tsx @@ -0,0 +1,123 @@ +import { useEffect, useMemo, useReducer, useState } from 'react'; +import { Download } from 'lucide-react'; +import FilterBar from '../components/FilterBar'; +import UploadButton from '../components/UploadButton'; +import MonthlyUtilisation from '../components/charts/MonthlyUtilisation'; +import BookingVsActual from '../components/charts/BookingVsActual'; +import BillabilityBreakdown from '../components/charts/BillabilityBreakdown'; +import Loading from '../components/Loading'; +import ErrorBox from '../components/ErrorBox'; +import { useAirtableData } from '../hooks/useAirtableData'; +import { useTimelog } from '../hooks/useTimelog'; +import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters'; +import { downloadCsv, rowsToCsv } from '../lib/csv'; +import * as api from '../api/endpoints'; +import { ApiError } from '../api/client'; +import type { UtilisationSummaryRow } from '../api/types'; + +export default function Department() { + const airtable = useAirtableData(false); + const tl = useTimelog(); + const [filters, dispatch] = useReducer(filterReducer, initialFilterState); + const [summary, setSummary] = useState([]); + const [summaryLoading, setSummaryLoading] = useState(false); + const [summaryError, setSummaryError] = useState(null); + + const names = useMemo( + () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), + [airtable.resources], + ); + + const loadSummary = useMemo( + () => async () => { + setSummaryLoading(true); + setSummaryError(null); + try { + const q = filtersToQuery(filters); + const res = await api.getUtilisationSummary({ + from: q.from, + to: q.to, + department: q.department, + name: q.name, + billing_type: q.billing_type, + timelogHash: tl.hash ?? undefined, + }); + setSummary(res.rows); + } catch (err) { + setSummaryError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setSummaryLoading(false); + } + }, + [filters, tl.hash], + ); + + useEffect(() => { + void loadSummary(); + }, [loadSummary]); + + const handleExport = () => { + if (summary.length === 0) return; + const csv = rowsToCsv(summary as unknown as Record[]); + downloadCsv(`department-utilisation-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv); + }; + + return ( +
+
+

How to Use the Department Tab

+
    +
  1. Upload your timelog export (.xlsx or .csv). It stays in this session only.
  2. +
  3. Pick a date preset (or custom range) and narrow by department or name.
  4. +
  5. The charts below recompute automatically. Toggle the forecast line to compare scenarios.
  6. +
  7. Use Export CSV to share the summary outside the app.
  8. +
+
+ +
+ void tl.upload(f)} + onClear={tl.clear} + /> + + +
+ +
+ +
+ + {airtable.error && } + {summaryError && } + + {(airtable.loading || summaryLoading) && } + + {!summaryError && summary.length > 0 && ( + <> + + + + + )} +
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..c16e732 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from 'react'; +import { Navigate, useNavigate } from 'react-router-dom'; +import { LogIn } from 'lucide-react'; +import { useAuth } from '../hooks/useAuth'; +import { ApiError } from '../api/client'; + +export default function Login() { + const { user, login, loading } = useAuth(); + const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [fieldError, setFieldError] = useState(null); + const [rateLimited, setRateLimited] = useState(false); + + // If already authenticated, send them home. + useEffect(() => { + if (!loading && user) navigate('/', { replace: true }); + }, [loading, user, navigate]); + + if (user) return ; + + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setFieldError(null); + setRateLimited(false); + setSubmitting(true); + try { + await login(username, password); + navigate('/', { replace: true }); + } catch (err) { + if (err instanceof ApiError) { + if (err.status === 401) setFieldError('Invalid username or password.'); + else if (err.status === 429) setRateLimited(true); + else setFieldError(err.detail || 'Login failed.'); + } else { + setFieldError((err as Error).message); + } + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+
+
+ +
+

L'Oréal Utilisation

+

Sign in to continue

+
+ + {rateLimited && ( +
+ Too many login attempts. Please wait a few minutes and try again. +
+ )} + +
+
+ + setUsername(e.target.value)} + className="input" + /> +
+
+ + setPassword(e.target.value)} + className="input" + /> +
+ + {fieldError && ( +
{fieldError}
+ )} + + +
+
+
+ ); +} diff --git a/frontend/src/pages/Resourcing.tsx b/frontend/src/pages/Resourcing.tsx new file mode 100644 index 0000000..311b351 --- /dev/null +++ b/frontend/src/pages/Resourcing.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import { Download } from 'lucide-react'; +import FilterBar from '../components/FilterBar'; +import WeeklyUtilisation from '../components/charts/WeeklyUtilisation'; +import ProjectLoadPerPerson from '../components/charts/ProjectLoadPerPerson'; +import FTEvsFreelancer from '../components/charts/FTEvsFreelancer'; +import Loading from '../components/Loading'; +import ErrorBox from '../components/ErrorBox'; +import { useAirtableData } from '../hooks/useAirtableData'; +import { filterReducer, filtersToQuery, initialFilterState } from '../lib/filters'; +import { downloadCsv, rowsToCsv } from '../lib/csv'; +import * as api from '../api/endpoints'; +import { ApiError } from '../api/client'; +import type { Booking, UtilisationSummaryRow } from '../api/types'; + +export default function Resourcing() { + const airtable = useAirtableData(false); + const [filters, dispatch] = useReducer(filterReducer, initialFilterState); + const [bookings, setBookings] = useState([]); + const [summary, setSummary] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedPeriod, setSelectedPeriod] = useState(null); + + const names = useMemo( + () => airtable.resources.map((r) => r.name).sort((a, b) => a.localeCompare(b)), + [airtable.resources], + ); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const q = filtersToQuery(filters); + const [bk, sm] = await Promise.all([ + api.getBookings({ from: q.from, to: q.to }), + api.getUtilisationSummary({ + from: q.from, + to: q.to, + department: q.department, + name: q.name, + billing_type: q.billing_type, + }), + ]); + setBookings(bk.bookings); + setSummary(sm.rows); + } catch (err) { + setError(err instanceof ApiError ? err.detail : (err as Error).message); + } finally { + setLoading(false); + } + }, [filters]); + + useEffect(() => { + void load(); + }, [load]); + + const handleExport = () => { + if (summary.length === 0) return; + const csv = rowsToCsv(summary as unknown as Record[]); + downloadCsv(`resourcing-utilisation-${filters.range?.from ?? 'all'}-to-${filters.range?.to ?? 'all'}`, csv); + }; + + return ( +
+ + +
+ {selectedPeriod ? ( +
+ Drilled into period {selectedPeriod}{' '} + +
+ ) : ( + + )} + +
+ + {error && } + {loading && } + + {!error && ( + <> + + + + + )} +
+ ); +} diff --git a/frontend/src/pages/Tutorial.tsx b/frontend/src/pages/Tutorial.tsx new file mode 100644 index 0000000..1ab4f44 --- /dev/null +++ b/frontend/src/pages/Tutorial.tsx @@ -0,0 +1,69 @@ +import { lazy, Suspense, useState } from 'react'; +import { Play } from 'lucide-react'; +import { allSteps, type TutorialSection } from '../components/tutorial/steps'; + +const TutorialOverlay = lazy(() => import('../components/tutorial/TutorialOverlay')); + +const SECTIONS: { key: TutorialSection; label: string; blurb: string }[] = [ + { + key: 'department', + label: 'Department', + blurb: 'Upload your timelog, filter by department and see monthly utilisation, booking-vs-actual and billability.', + }, + { + key: 'resourcing', + label: 'Resourcing', + blurb: 'Weekly utilisation, per-person project load, and FTE-vs-Freelancer side-by-side.', + }, + { + key: 'bookings', + label: 'Bookings', + blurb: 'Virtualised booking table with cache info and an Airtable sync button.', + }, +]; + +export default function Tutorial() { + const [activeSection, setActiveSection] = useState(null); + + return ( +
+
+

Tutorial

+

+ A walkthrough of every tab. Click Replay to relaunch the guided tour for that section — it only + highlights elements that are currently visible, so navigate to the matching tab first. +

+
+ +
+ {SECTIONS.map((s) => ( +
+

{s.label}

+

{s.blurb}

+ +
    + {allSteps[s.key].map((step) => ( +
  • + {step.title}: {step.description} +
  • + ))} +
+
+ ))} +
+ + {activeSection && ( + + setActiveSection(null)} /> + + )} +
+ ); +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..bee02a3 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,15 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./index.html', './src/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + brand: { + DEFAULT: '#0f172a', // slate-900 + accent: '#2563eb', // blue-600 + }, + }, + }, + }, + plugins: [], +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..f6d937e --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "types": ["vite/client"] + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c7c82 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true, + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "outDir": "./.tsbuild-node" + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..e6b1ee9 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +// https://vitejs.dev/config/ +export default defineConfig({ + base: '/utilisation-dept/', + plugins: [react()], + server: { + port: 5173, + proxy: { + '/utilisation-dept/api': { + target: 'http://localhost:8200', + changeOrigin: false, + rewrite: (p) => p.replace('/utilisation-dept', ''), + }, + }, + }, + build: { + outDir: 'dist', + sourcemap: false, + rollupOptions: { + output: { + manualChunks: { + recharts: ['recharts'], + driver: ['driver.js'], + }, + }, + }, + }, +});