diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..2747f74256db647180b57fc29b84a2cae9a1b082 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,51 @@ +FROM node:20-alpine AS frontend-build + +WORKDIR /frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci +COPY frontend/ . +RUN npm run build + +FROM python:3.11-slim + +WORKDIR /app + +# Create non-root user (required by HF Spaces) +RUN useradd -m -u 1000 appuser + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/requirements.txt . + +# Install CPU-only torch first (much smaller), skip problematic deps +RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \ + pip install --no-cache-dir \ + $(grep -v '^#' requirements.txt | grep -v '^$' | grep -v '^torch==' | grep -v 'pycld3' | grep -v 'weasyprint' | tr '\n' ' ') && \ + pip install --no-cache-dir reportlab + +COPY backend/ . + +# Copy frontend build into backend static dir +COPY --from=frontend-build /frontend/dist /app/static + +# Copy demo data +COPY demo_data/ /app/demo_data/ + +RUN mkdir -p uploads model_cache data && \ + chown -R appuser:appuser /app + +ENV PORT=7860 +ENV APP_ENV=production +ENV LOG_LEVEL=INFO +ENV TRANSFORMERS_CACHE=/app/model_cache +ENV SENTENCE_TRANSFORMERS_HOME=/app/model_cache +ENV HF_HOME=/app/model_cache + +USER appuser + +EXPOSE 7860 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"] + diff --git a/README.md b/README.md index b1782823bf7f7f45cf9847280706670a5a7d929d..8cea418193f7759cea8e25d9f73a1d3a53b04a7c 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,30 @@ colorFrom: green colorTo: blue sdk: docker pinned: false +app_port: 7860 --- -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# 📊 Sentiment & Topic Analysis Dashboard + +Upload CSV, JSON, or Excel files containing customer feedback, support tickets, or reviews — get instant multilingual sentiment analysis, topic clustering, anomaly detection, and interactive visualizations. + +## Features +- **Multilingual sentiment analysis** using `cardiffnlp/twitter-xlm-roberta-base-sentiment` +- **Dynamic topic clustering** with BERTopic (HDBSCAN + UMAP) +- **Interactive force-directed** topic cluster graph +- **Sentiment trend charts** with confidence intervals +- **Data quality dashboard** flagging low-confidence predictions, mixed languages, duplicates +- **Comparison mode** to contrast time periods or segments +- **Export** to CSV, JSON, or PDF +- **Dark mode** support + +## Usage +1. Upload a file with text data (CSV, JSON, Excel) +2. Wait for analysis to complete (~30s for 50 entries) +3. Explore the dashboard tabs: Overview, Data Quality, Compare + +**API Key**: Use `dev-key-1` (pre-configured in the UI) + +## Tech Stack +- **Backend**: FastAPI, PyTorch, Transformers, BERTopic +- **Frontend**: React, TypeScript, Recharts, D3.js diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..96151e9c6811086b1741f7e8db4099d13265e35d --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p uploads model_cache data + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD python -c "import httpx; r = httpx.get('http://localhost:8000/health/live'); r.raise_for_status()" + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/api/analysis.py b/backend/app/api/analysis.py new file mode 100644 index 0000000000000000000000000000000000000000..225204edd1697a10bb350cb366af88d09f8fa291 --- /dev/null +++ b/backend/app/api/analysis.py @@ -0,0 +1,290 @@ +"""Upload and analysis API endpoints.""" + +from __future__ import annotations + +import uuid +from typing import Optional + +from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Query, UploadFile + +from app.core.config import settings +from app.core.logging import get_logger +from app.core.security import get_api_key +from app.models.schemas import ( + AnalysisResult, + AnalysisStatus, + ComparisonRequest, + ComparisonResult, + FilterParams, + JobStatus, + TopicInfo, +) +from app.services.analysis_pipeline import ( + filter_entries, + get_all_jobs, + get_job, + run_analysis, +) +from app.services.file_processing import parse_file + +logger = get_logger(__name__) +router = APIRouter(prefix="/api/v1", tags=["analysis"]) + + +@router.post("/upload", response_model=JobStatus) +async def upload_file( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + source: Optional[str] = Query(None, description="Data source label"), + api_key: str = Depends(get_api_key), +): + """Upload a file for analysis. Supports CSV, JSON, Excel, ZIP.""" + if not file.filename: + raise HTTPException(status_code=400, detail="No filename provided") + + content = await file.read() + size_mb = len(content) / (1024 * 1024) + + if size_mb > settings.max_upload_size_mb: + raise HTTPException( + status_code=413, + detail=f"File too large ({size_mb:.1f}MB). Maximum: {settings.max_upload_size_mb}MB", + ) + + try: + entries = parse_file(content, file.filename, source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + if not entries: + raise HTTPException(status_code=400, detail="No valid entries found in the uploaded file") + + job_id = uuid.uuid4().hex[:12] + logger.info("upload_received", job_id=job_id, filename=file.filename, entries=len(entries), size_mb=round(size_mb, 2)) + + background_tasks.add_task(run_analysis, entries, job_id) + + from datetime import datetime + + return JobStatus( + job_id=job_id, + status=AnalysisStatus.PENDING, + progress=0.0, + message=f"Processing {len(entries)} entries from {file.filename}", + created_at=datetime.utcnow(), + ) + + +@router.post("/upload/chunked", response_model=JobStatus) +async def upload_chunked( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + chunk_index: int = Query(0, ge=0), + total_chunks: int = Query(1, ge=1), + upload_id: Optional[str] = Query(None), + source: Optional[str] = Query(None), + api_key: str = Depends(get_api_key), +): + """Chunked upload for files >10MB.""" + from pathlib import Path + + upload_id = upload_id or uuid.uuid4().hex[:12] + chunk_dir = settings.upload_path / f"chunks_{upload_id}" + chunk_dir.mkdir(parents=True, exist_ok=True) + + content = await file.read() + chunk_path = chunk_dir / f"chunk_{chunk_index:04d}" + chunk_path.write_bytes(content) + + logger.info("chunk_received", upload_id=upload_id, chunk=chunk_index, total=total_chunks) + + if chunk_index + 1 < total_chunks: + from datetime import datetime + + return JobStatus( + job_id=upload_id, + status=AnalysisStatus.PENDING, + progress=chunk_index / total_chunks, + message=f"Received chunk {chunk_index + 1}/{total_chunks}", + created_at=datetime.utcnow(), + ) + + # All chunks received — reassemble + chunks = sorted(chunk_dir.glob("chunk_*")) + combined = b"".join(c.read_bytes() for c in chunks) + + # Clean up chunks + for c in chunks: + c.unlink() + chunk_dir.rmdir() + + try: + filename = file.filename or "upload.csv" + entries = parse_file(combined, filename, source) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + + if not entries: + raise HTTPException(status_code=400, detail="No valid entries found") + + background_tasks.add_task(run_analysis, entries, upload_id) + + from datetime import datetime + + return JobStatus( + job_id=upload_id, + status=AnalysisStatus.PROCESSING, + progress=0.0, + message=f"All chunks received. Processing {len(entries)} entries.", + created_at=datetime.utcnow(), + ) + + +@router.get("/jobs", response_model=list[JobStatus]) +async def list_jobs(api_key: str = Depends(get_api_key)): + """List all analysis jobs.""" + jobs = get_all_jobs() + return [ + JobStatus( + job_id=j.job_id, + status=j.status, + progress=1.0 if j.status == AnalysisStatus.COMPLETED else 0.5, + message="", + created_at=j.created_at, + completed_at=j.completed_at, + ) + for j in jobs + ] + + +@router.get("/jobs/{job_id}", response_model=AnalysisResult) +async def get_job_result(job_id: str, api_key: str = Depends(get_api_key)): + """Get analysis results for a specific job.""" + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + return job + + +@router.get("/jobs/{job_id}/status", response_model=JobStatus) +async def get_job_status(job_id: str, api_key: str = Depends(get_api_key)): + """Get status of an analysis job.""" + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + return JobStatus( + job_id=job.job_id, + status=job.status, + progress=1.0 if job.status == AnalysisStatus.COMPLETED else 0.5, + message="", + created_at=job.created_at, + completed_at=job.completed_at, + ) + + +@router.post("/jobs/{job_id}/filter") +async def filter_job_results( + job_id: str, + filters: FilterParams, + api_key: str = Depends(get_api_key), +): + """Filter analysis results.""" + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.status != AnalysisStatus.COMPLETED: + raise HTTPException(status_code=400, detail="Analysis not yet completed") + + filtered = filter_entries( + job.entries, + date_from=filters.date_from, + date_to=filters.date_to, + sentiment_min=filters.sentiment_min, + sentiment_max=filters.sentiment_max, + topics=filters.topics, + languages=filters.languages, + sources=filters.sources, + search_text=filters.search_text, + ) + + # Paginate + start = (filters.page - 1) * filters.page_size + end = start + filters.page_size + + return { + "total": len(filtered), + "page": filters.page, + "page_size": filters.page_size, + "entries": filtered[start:end], + } + + +@router.post("/jobs/{job_id}/compare", response_model=ComparisonResult) +async def compare_segments( + job_id: str, + comparison: ComparisonRequest, + api_key: str = Depends(get_api_key), +): + """Compare two data segments from the same job.""" + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.status != AnalysisStatus.COMPLETED: + raise HTTPException(status_code=400, detail="Analysis not yet completed") + + from collections import Counter + + import numpy as np + + from app.models.schemas import AnalysisSummary, SentimentLabel + + seg_a_entries = filter_entries( + job.entries, **comparison.segment_a.model_dump(exclude={"page", "page_size"}) + ) + seg_b_entries = filter_entries( + job.entries, **comparison.segment_b.model_dump(exclude={"page", "page_size"}) + ) + + def make_summary(entries): + if not entries: + return AnalysisSummary( + total_entries=0, avg_sentiment=0.5, + dominant_sentiment=SentimentLabel.NEUTRAL, + num_topics=0, top_topics=[], languages_detected=[], + ) + sentiments = [e.sentiment for e in entries] + topic_counts = Counter(e.topic_id for e in entries) + return AnalysisSummary( + total_entries=len(entries), + avg_sentiment=round(float(np.mean([s.score for s in sentiments])), 4), + dominant_sentiment=SentimentLabel( + Counter(s.label.value for s in sentiments).most_common(1)[0][0] + ), + num_topics=len(set(e.topic_id for e in entries) - {-1}), + top_topics=[ + TopicInfo(topic_id=tid, label=f"Topic {tid}", keywords=[], size=cnt) + for tid, cnt in topic_counts.most_common(5) if tid != -1 + ], + languages_detected=list(set(e.language.language for e in entries)), + ) + + sum_a = make_summary(seg_a_entries) + sum_b = make_summary(seg_b_entries) + + topics_a = set(e.topic_id for e in seg_a_entries) - {-1} + topics_b = set(e.topic_id for e in seg_b_entries) - {-1} + + return ComparisonResult( + segment_a=sum_a, + segment_b=sum_b, + sentiment_delta=round(sum_b.avg_sentiment - sum_a.avg_sentiment, 4), + topic_changes=[], + new_topics=[ + TopicInfo(topic_id=t, label=f"Topic {t}", keywords=[], size=0) + for t in topics_b - topics_a + ], + disappeared_topics=[ + TopicInfo(topic_id=t, label=f"Topic {t}", keywords=[], size=0) + for t in topics_a - topics_b + ], + ) diff --git a/backend/app/api/export.py b/backend/app/api/export.py new file mode 100644 index 0000000000000000000000000000000000000000..f4c1d1f0ec23e328811f037205e5f700b283c770 --- /dev/null +++ b/backend/app/api/export.py @@ -0,0 +1,75 @@ +"""Export API endpoints.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response + +from app.core.security import get_api_key +from app.models.schemas import AnalysisStatus, ExportFormat, FilterParams +from app.services.analysis_pipeline import filter_entries, get_job +from app.services.export import export_entries + +router = APIRouter(prefix="/api/v1", tags=["export"]) + + +CONTENT_TYPES = { + ExportFormat.CSV: "text/csv", + ExportFormat.JSON: "application/json", + ExportFormat.PDF: "application/pdf", +} + +FILE_EXTENSIONS = { + ExportFormat.CSV: "csv", + ExportFormat.JSON: "json", + ExportFormat.PDF: "pdf", +} + + +@router.post("/jobs/{job_id}/export") +async def export_results( + job_id: str, + fmt: ExportFormat = ExportFormat.CSV, + filters: FilterParams | None = None, + api_key: str = Depends(get_api_key), +): + """Export filtered analysis results.""" + job = get_job(job_id) + if not job: + raise HTTPException(status_code=404, detail=f"Job {job_id} not found") + if job.status != AnalysisStatus.COMPLETED: + raise HTTPException(status_code=400, detail="Analysis not yet completed") + + entries = job.entries + if filters: + entries = filter_entries( + entries, + date_from=filters.date_from, + date_to=filters.date_to, + sentiment_min=filters.sentiment_min, + sentiment_max=filters.sentiment_max, + topics=filters.topics, + languages=filters.languages, + sources=filters.sources, + search_text=filters.search_text, + ) + + summary = None + if job.summary: + summary = { + "Total Entries": job.summary.total_entries, + "Average Sentiment": job.summary.avg_sentiment, + "Dominant Sentiment": job.summary.dominant_sentiment.value, + "Topics Found": job.summary.num_topics, + "Languages": ", ".join(job.summary.languages_detected), + } + + content = export_entries(entries, fmt, summary) + + return Response( + content=content, + media_type=CONTENT_TYPES[fmt], + headers={ + "Content-Disposition": f"attachment; filename=analysis_{job_id}.{FILE_EXTENSIONS[fmt]}" + }, + ) diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000000000000000000000000000000000000..81cceb12c07157dd308101d626d1ba9b95c02a53 --- /dev/null +++ b/backend/app/api/health.py @@ -0,0 +1,42 @@ +"""Health and system endpoints.""" + +from __future__ import annotations + +import time + +from fastapi import APIRouter + +from app.core.config import settings +from app.models.schemas import HealthResponse +from app.services.redis_client import check_redis_health +from app.services.sentiment import is_model_available + +router = APIRouter(tags=["system"]) + +_start_time = time.time() + + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + redis_ok = await check_redis_health() + return HealthResponse( + status="healthy" if redis_ok else "degraded", + version="1.0.0", + models_loaded=is_model_available(), + redis_connected=redis_ok, + uptime_seconds=round(time.time() - _start_time, 2), + ) + + +@router.get("/health/live") +async def liveness(): + return {"status": "alive"} + + +@router.get("/health/ready") +async def readiness(): + redis_ok = await check_redis_health() + if not redis_ok: + from fastapi import HTTPException + raise HTTPException(status_code=503, detail="Redis not available") + return {"status": "ready"} diff --git a/backend/app/api/webhooks.py b/backend/app/api/webhooks.py new file mode 100644 index 0000000000000000000000000000000000000000..42cba09733ea9872cad562b96bf09b10c41797d6 --- /dev/null +++ b/backend/app/api/webhooks.py @@ -0,0 +1,107 @@ +"""Webhook and SSE endpoints for real-time ingestion.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import datetime + +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request +from sse_starlette.sse import EventSourceResponse + +from app.core.logging import get_logger +from app.core.security import get_api_key, verify_webhook_signature +from app.models.schemas import AnalysisStatus, FeedbackEntry, JobStatus, WebhookPayload +from app.services.analysis_pipeline import run_analysis +from app.services.redis_client import subscribe_events + +logger = get_logger(__name__) +router = APIRouter(prefix="/api/v1", tags=["realtime"]) + + +@router.post("/webhooks/ingest", response_model=JobStatus) +async def webhook_ingest( + request: Request, + background_tasks: BackgroundTasks, +): + """Receive data via webhook with Stripe-style signature verification.""" + body = await request.body() + signature = request.headers.get("X-Signature", "") + timestamp = request.headers.get("X-Timestamp", "") + + if not verify_webhook_signature(body, signature, timestamp): + raise HTTPException(status_code=401, detail="Invalid webhook signature") + + try: + payload = WebhookPayload.model_validate_json(body) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid payload: {exc}") + + if not payload.data: + raise HTTPException(status_code=400, detail="No data entries in payload") + + import uuid + + job_id = uuid.uuid4().hex[:12] + logger.info("webhook_received", job_id=job_id, event=payload.event_type, entries=len(payload.data)) + + entries = [ + FeedbackEntry( + id=e.id, + text=e.text, + source=payload.source or e.source or "webhook", + timestamp=e.timestamp or datetime.utcnow(), + metadata=e.metadata, + ) + for e in payload.data + ] + + background_tasks.add_task(run_analysis, entries, job_id) + + return JobStatus( + job_id=job_id, + status=AnalysisStatus.PENDING, + progress=0.0, + message=f"Webhook: processing {len(entries)} entries", + created_at=datetime.utcnow(), + ) + + +@router.get("/events/analysis") +async def analysis_events(request: Request, api_key: str = Depends(get_api_key)): + """Server-Sent Events stream for live analysis updates.""" + + async def event_generator(): + try: + async for data in subscribe_events("analysis_updates"): + if await request.is_disconnected(): + break + yield { + "event": "analysis_update", + "data": json.dumps(data), + } + except asyncio.CancelledError: + pass + except Exception as exc: + logger.error("sse_error", error=str(exc)) + + return EventSourceResponse(event_generator()) + + +@router.get("/events/anomalies") +async def anomaly_events(request: Request, api_key: str = Depends(get_api_key)): + """SSE stream for anomaly alerts.""" + + async def event_generator(): + try: + async for data in subscribe_events("anomaly_alerts"): + if await request.is_disconnected(): + break + yield { + "event": "anomaly_alert", + "data": json.dumps(data), + } + except asyncio.CancelledError: + pass + + return EventSourceResponse(event_generator()) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..986ca5691157c2ab86402e1c297c105ed2d419dd --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,103 @@ +"""Application configuration using pydantic-settings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import List + +from pydantic import field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + # Application + app_name: str = "TopicAnalysis" + app_env: str = "development" + debug: bool = False + secret_key: str = "change-me-in-production" + api_key_header: str = "X-API-Key" + allowed_api_keys: List[str] = ["dev-key-1"] + + # Server + backend_host: str = "0.0.0.0" + backend_port: int = 8000 + frontend_url: str = "http://localhost:3000" + cors_origins: List[str] = ["http://localhost:3000", "http://localhost:8080"] + + # Redis + redis_url: str = "redis://localhost:6379/0" + + # File Upload + max_upload_size_mb: int = 500 + chunk_size_mb: int = 10 + upload_dir: str = "./uploads" + + # ML Models + sentiment_model: str = "cardiffnlp/twitter-xlm-roberta-base-sentiment" + embedding_model: str = "paraphrase-multilingual-MiniLM-L12-v2" + model_cache_dir: str = "./model_cache" + model_load_timeout: int = 120 + + # Rate Limiting + rate_limit_per_minute: int = 60 + rate_limit_burst: int = 10 + + # Anomaly Detection + anomaly_rolling_window: int = 50 + anomaly_sentiment_threshold: float = 1.5 + anomaly_topic_spike_threshold: float = 3.0 + + # Notifications + slack_webhook_url: str = "" + notification_email_from: str = "" + notification_email_to: str = "" + smtp_host: str = "" + smtp_port: int = 587 + smtp_user: str = "" + smtp_password: str = "" + + # Webhook + webhook_secret: str = "whsec_change-me" + + # Observability + otel_exporter_otlp_endpoint: str = "http://localhost:4317" + otel_service_name: str = "topic-analysis" + log_level: str = "INFO" + log_format: str = "json" + + # Database + database_url: str = "sqlite:///./data/analysis.db" + + @field_validator("allowed_api_keys", mode="before") + @classmethod + def parse_api_keys(cls, v: str | list) -> list: + if isinstance(v, str): + return [k.strip() for k in v.split(",") if k.strip()] + return v + + @field_validator("cors_origins", mode="before") + @classmethod + def parse_cors(cls, v: str | list) -> list: + if isinstance(v, str): + return [o.strip() for o in v.split(",") if o.strip()] + return v + + @property + def upload_path(self) -> Path: + p = Path(self.upload_dir) + p.mkdir(parents=True, exist_ok=True) + return p + + @property + def is_production(self) -> bool: + return self.app_env == "production" + + +settings = Settings() diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..05aa011d88f47aed5766a883d3f86905e53f6942 --- /dev/null +++ b/backend/app/core/logging.py @@ -0,0 +1,81 @@ +"""Structured logging with correlation IDs.""" + +from __future__ import annotations + +import logging +import sys +import uuid +from contextvars import ContextVar + +import structlog + +correlation_id_var: ContextVar[str] = ContextVar("correlation_id", default="") + + +def get_correlation_id() -> str: + cid = correlation_id_var.get() + if not cid: + cid = uuid.uuid4().hex[:16] + correlation_id_var.set(cid) + return cid + + +def add_correlation_id( + logger: structlog.types.WrappedLogger, + method_name: str, + event_dict: dict, +) -> dict: + event_dict["correlation_id"] = get_correlation_id() + return event_dict + + +def setup_logging(log_level: str = "INFO", log_format: str = "json") -> None: + shared_processors: list = [ + structlog.contextvars.merge_contextvars, + add_correlation_id, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if log_format == "json": + renderer = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + foreign_pre_chain=shared_processors, + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + for name in ("uvicorn", "uvicorn.access", "uvicorn.error"): + lg = logging.getLogger(name) + lg.handlers.clear() + lg.propagate = True + + +def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: + return structlog.get_logger(name) diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..590bbfa6fcd4ae0057e701ec15462d7438f380ba --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,39 @@ +"""FastAPI middleware for correlation IDs, request logging, and error handling.""" + +from __future__ import annotations + +import time +import uuid + +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core.logging import correlation_id_var, get_logger + +logger = get_logger(__name__) + + +class CorrelationIdMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + cid = request.headers.get("X-Correlation-ID", uuid.uuid4().hex[:16]) + correlation_id_var.set(cid) + start = time.perf_counter() + + response: Response = await call_next(request) + + duration_ms = round((time.perf_counter() - start) * 1000, 2) + response.headers["X-Correlation-ID"] = cid + response.headers["X-Response-Time-Ms"] = str(duration_ms) + + logger.info( + "request_completed", + method=request.method, + path=request.url.path, + status_code=response.status_code, + duration_ms=duration_ms, + ) + return response + + +def register_middleware(app: FastAPI) -> None: + app.add_middleware(CorrelationIdMiddleware) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000000000000000000000000000000000000..02055524c7aee82299339d4a67d17fd24e1b5f84 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,63 @@ +"""Security utilities: API key validation, webhook signature verification, rate limiting.""" + +from __future__ import annotations + +import hashlib +import hmac +import time +from typing import Optional + +from fastapi import HTTPException, Request, Security +from fastapi.security import APIKeyHeader +from slowapi import Limiter +from slowapi.util import get_remote_address + +from app.core.config import settings + +api_key_header = APIKeyHeader(name=settings.api_key_header, auto_error=False) + + +def get_api_key(api_key: Optional[str] = Security(api_key_header)) -> str: + if not api_key or api_key not in settings.allowed_api_keys: + raise HTTPException(status_code=403, detail="Invalid or missing API key") + return api_key + + +def _key_func(request: Request) -> str: + api_key = request.headers.get(settings.api_key_header, "") + if api_key: + return api_key + return get_remote_address(request) + + +limiter = Limiter(key_func=_key_func, default_limits=[f"{settings.rate_limit_per_minute}/minute"]) + + +def verify_webhook_signature(payload: bytes, signature: str, timestamp: str) -> bool: + """Verify Stripe-style webhook signature (t=timestamp,v1=signature).""" + if not signature or not timestamp: + return False + + try: + ts = int(timestamp) + except (ValueError, TypeError): + return False + + if abs(time.time() - ts) > 300: + return False + + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + expected = hmac.new( + settings.webhook_secret.encode("utf-8"), + signed_payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + parts = signature.split(",") + for part in parts: + if part.startswith("v1="): + sig_value = part[3:] + if hmac.compare_digest(expected, sig_value): + return True + + return False diff --git a/backend/app/core/telemetry.py b/backend/app/core/telemetry.py new file mode 100644 index 0000000000000000000000000000000000000000..1b8d142d38aefdb1d5ba097c903909347ab8eb52 --- /dev/null +++ b/backend/app/core/telemetry.py @@ -0,0 +1,46 @@ +"""OpenTelemetry and Prometheus instrumentation setup.""" + +from __future__ import annotations + +from fastapi import FastAPI +from prometheus_fastapi_instrumentator import Instrumentator + +from app.core.config import settings +from app.core.logging import get_logger + +logger = get_logger(__name__) + + +def setup_telemetry(app: FastAPI) -> None: + """Initialize OpenTelemetry tracing and Prometheus metrics.""" + # Prometheus metrics + Instrumentator( + should_group_status_codes=True, + should_ignore_untemplated=True, + excluded_handlers=["/health", "/metrics"], + ).instrument(app).expose(app, endpoint="/metrics") + + # OpenTelemetry — only in production to avoid dev noise + if settings.is_production: + try: + from opentelemetry import trace + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor + from opentelemetry.sdk.resources import Resource + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + + resource = Resource.create({"service.name": settings.otel_service_name}) + provider = TracerProvider(resource=resource) + exporter = OTLPSpanExporter(endpoint=settings.otel_exporter_otlp_endpoint) + provider.add_span_processor(BatchSpanProcessor(exporter)) + trace.set_tracer_provider(provider) + + FastAPIInstrumentor.instrument_app(app) + logger.info("opentelemetry_initialized") + except ImportError: + logger.warning("opentelemetry_not_available", detail="Install opentelemetry packages") + except Exception as exc: + logger.error("opentelemetry_setup_failed", error=str(exc)) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..d259b79ed210ad869342cce119b91ccd89dcddb9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,103 @@ +"""FastAPI application entry point.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + +from app.api import analysis, export, health, webhooks +from app.core.config import settings +from app.core.logging import get_logger, setup_logging +from app.core.middleware import register_middleware +from app.core.security import limiter +from app.core.telemetry import setup_telemetry +from app.services.redis_client import close_redis + +setup_logging(settings.log_level, settings.log_format) +logger = get_logger(__name__) + +STATIC_DIR = Path(__file__).parent.parent / "static" + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info( + "application_starting", + app_name=settings.app_name, + env=settings.app_env, + ) + settings.upload_path # Ensure upload directory exists + yield + logger.info("application_shutting_down") + await close_redis() + + +app = FastAPI( + title=settings.app_name, + description="Sentiment & Topic Analysis Dashboard API", + version="1.0.0", + lifespan=lifespan, + docs_url="/docs" if not settings.is_production else None, + redoc_url="/redoc" if not settings.is_production else None, +) + +# Middleware +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +register_middleware(app) +setup_telemetry(app) + +# Routes +app.include_router(health.router) +app.include_router(analysis.router) +app.include_router(export.router) +app.include_router(webhooks.router) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + from app.core.logging import get_correlation_id + + logger.error( + "unhandled_exception", + path=request.url.path, + error=str(exc), + exc_info=True, + ) + return JSONResponse( + status_code=500, + content={ + "detail": "Internal server error", + "correlation_id": get_correlation_id(), + }, + ) + + +# Serve frontend static files in production (when static/ dir exists) +if STATIC_DIR.is_dir(): + app.mount("/assets", StaticFiles(directory=str(STATIC_DIR / "assets")), name="static-assets") + + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + """Serve the SPA index.html for all non-API routes.""" + file_path = STATIC_DIR / full_path + if file_path.is_file(): + return FileResponse(str(file_path)) + return FileResponse(str(STATIC_DIR / "index.html")) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..38523c6b8950babbc77f38b8b9f7ab7751111c88 --- /dev/null +++ b/backend/app/models/schemas.py @@ -0,0 +1,240 @@ +"""Pydantic schemas for all data models.""" + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# --- Enums --- + + +class SentimentLabel(str, Enum): + POSITIVE = "positive" + NEGATIVE = "negative" + NEUTRAL = "neutral" + + +class ExportFormat(str, Enum): + CSV = "csv" + JSON = "json" + PDF = "pdf" + + +class AnomalyType(str, Enum): + SENTIMENT_DROP = "sentiment_drop" + TOPIC_SPIKE = "topic_spike" + + +class AnalysisStatus(str, Enum): + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +# --- Request Models --- + + +class FeedbackEntry(BaseModel): + id: Optional[str] = None + text: str = Field(..., min_length=1, max_length=50000) + source: Optional[str] = None + timestamp: Optional[datetime] = None + metadata: Optional[Dict[str, Any]] = None + + +class AnalysisRequest(BaseModel): + entries: List[FeedbackEntry] = Field(..., min_items=1) + options: Optional[AnalysisOptions] = None + + +class AnalysisOptions(BaseModel): + min_cluster_size: int = Field(default=5, ge=2, le=100) + min_samples: int = Field(default=3, ge=1, le=50) + detect_anomalies: bool = True + language_filter: Optional[str] = None + + +class FilterParams(BaseModel): + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + sentiment_min: Optional[float] = Field(default=None, ge=-1.0, le=1.0) + sentiment_max: Optional[float] = Field(default=None, ge=-1.0, le=1.0) + topics: Optional[List[int]] = None + languages: Optional[List[str]] = None + sources: Optional[List[str]] = None + search_text: Optional[str] = None + page: int = Field(default=1, ge=1) + page_size: int = Field(default=50, ge=1, le=500) + + +class ComparisonRequest(BaseModel): + segment_a: FilterParams + segment_b: FilterParams + + +class WebhookPayload(BaseModel): + event_type: str + data: List[FeedbackEntry] + source: Optional[str] = None + + +class AnomalyThresholds(BaseModel): + sentiment_threshold: float = Field(default=1.5, ge=0.1, le=5.0) + topic_spike_threshold: float = Field(default=3.0, ge=1.0, le=10.0) + rolling_window: int = Field(default=50, ge=10, le=1000) + + +# --- Response Models --- + + +class SentimentResult(BaseModel): + label: SentimentLabel + score: float = Field(..., ge=0.0, le=1.0) + confidence: float = Field(..., ge=0.0, le=1.0) + + +class LanguageResult(BaseModel): + language: str + confidence: float = Field(..., ge=0.0, le=1.0) + method: str # "langdetect" or "cld3" + + +class TopicInfo(BaseModel): + topic_id: int + label: str + keywords: List[str] + size: int + representative_docs: List[str] = Field(default_factory=list) + + +class AnalyzedEntry(BaseModel): + id: str + text: str + source: Optional[str] = None + timestamp: Optional[datetime] = None + sentiment: SentimentResult + language: LanguageResult + topic_id: int + topic_label: str + embedding: Optional[List[float]] = None + metadata: Optional[Dict[str, Any]] = None + + +class TopicCluster(BaseModel): + topic_id: int + label: str + keywords: List[str] + size: int + avg_sentiment: float + sentiment_distribution: Dict[str, int] + languages: Dict[str, int] + representative_docs: List[str] + + +class SentimentTrend(BaseModel): + period: str + avg_sentiment: float + count: int + positive: int + negative: int + neutral: int + confidence_lower: float + confidence_upper: float + + +class TopicLink(BaseModel): + source: int + target: int + weight: float + + +class TopicGraph(BaseModel): + nodes: List[TopicCluster] + links: List[TopicLink] + + +class DataQualityReport(BaseModel): + total_entries: int + low_confidence_count: int + low_confidence_entries: List[str] + mixed_language_count: int + mixed_language_entries: List[str] + duplicate_count: int + duplicate_entries: List[str] + avg_confidence: float + language_distribution: Dict[str, int] + + +class AnomalyAlert(BaseModel): + id: str + type: AnomalyType + severity: str + message: str + detected_at: datetime + details: Dict[str, Any] + + +class AnalysisResult(BaseModel): + job_id: str + status: AnalysisStatus + created_at: datetime + completed_at: Optional[datetime] = None + total_entries: int + entries: List[AnalyzedEntry] = Field(default_factory=list) + topics: List[TopicCluster] = Field(default_factory=list) + sentiment_trends: List[SentimentTrend] = Field(default_factory=list) + topic_graph: Optional[TopicGraph] = None + data_quality: Optional[DataQualityReport] = None + anomalies: List[AnomalyAlert] = Field(default_factory=list) + summary: Optional[AnalysisSummary] = None + + +class AnalysisSummary(BaseModel): + total_entries: int + avg_sentiment: float + dominant_sentiment: SentimentLabel + num_topics: int + top_topics: List[TopicInfo] + languages_detected: List[str] + date_range: Optional[Dict[str, str]] = None + + +class ComparisonResult(BaseModel): + segment_a: AnalysisSummary + segment_b: AnalysisSummary + sentiment_delta: float + topic_changes: List[Dict[str, Any]] + new_topics: List[TopicInfo] + disappeared_topics: List[TopicInfo] + + +class JobStatus(BaseModel): + job_id: str + status: AnalysisStatus + progress: float = Field(default=0.0, ge=0.0, le=1.0) + message: str = "" + created_at: datetime + completed_at: Optional[datetime] = None + + +class HealthResponse(BaseModel): + status: str + version: str + models_loaded: bool + redis_connected: bool + uptime_seconds: float + + +class ErrorResponse(BaseModel): + detail: str + correlation_id: Optional[str] = None + code: Optional[str] = None + + +# Fix forward references +AnalysisRequest.model_rebuild() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/services/analysis_pipeline.py b/backend/app/services/analysis_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..2714c00a4888d4c6d3b6ea1c13129d7856b5aacc --- /dev/null +++ b/backend/app/services/analysis_pipeline.py @@ -0,0 +1,338 @@ +"""Analysis pipeline orchestrator — coordinates all ML services.""" + +from __future__ import annotations + +import uuid +from collections import Counter +from datetime import datetime +from typing import Any, Dict, List, Optional + +import numpy as np + +from app.core.logging import get_logger +from app.models.schemas import ( + AnalysisResult, + AnalysisSummary, + AnalysisStatus, + AnalyzedEntry, + FeedbackEntry, + SentimentLabel, + SentimentTrend, + TopicInfo, +) +from app.services.anomaly_detection import run_anomaly_detection +from app.services.data_quality import analyze_data_quality +from app.services.language_detection import detect_languages_batch +from app.services.notifications import notify_anomalies +from app.services.redis_client import publish_event +from app.services.sentiment import ( + analyze_sentiment, + get_fallback_sentiment, + is_model_available, +) +from app.services.topic_clustering import ( + build_topic_graph, + cluster_topics, + compute_embeddings, + is_embedding_model_available, +) + +logger = get_logger(__name__) + +# In-memory job store (production would use a database) +_jobs: Dict[str, AnalysisResult] = {} + + +async def run_analysis( + entries: list[FeedbackEntry], + job_id: Optional[str] = None, + detect_anomalies: bool = True, + min_cluster_size: Optional[int] = None, + min_samples: Optional[int] = None, +) -> AnalysisResult: + """Run the full analysis pipeline.""" + job_id = job_id or uuid.uuid4().hex[:12] + now = datetime.utcnow() + + result = AnalysisResult( + job_id=job_id, + status=AnalysisStatus.PROCESSING, + created_at=now, + total_entries=len(entries), + ) + _jobs[job_id] = result + + await publish_event("analysis_updates", {"job_id": job_id, "status": "processing", "progress": 0.0}) + + try: + import time as _time + + texts = [e.text for e in entries] + logger.info("pipeline_started", job_id=job_id, entry_count=len(texts), + sample_text=texts[0][:100] if texts else "") + + # Step 1: Language detection + t0 = _time.time() + logger.info("pipeline_step", step="language_detection", count=len(texts)) + languages = detect_languages_batch(texts) + lang_counts = {} + for l in languages: + lang_counts[l.language] = lang_counts.get(l.language, 0) + 1 + logger.info("language_detection_complete", elapsed=round(_time.time() - t0, 2), + language_distribution=str(lang_counts)) + await publish_event("analysis_updates", {"job_id": job_id, "status": "processing", "progress": 0.2}) + + # Step 2: Sentiment analysis + t0 = _time.time() + model_available = is_model_available() + logger.info("pipeline_step", step="sentiment_analysis", count=len(texts), + model_available=model_available) + if model_available: + sentiments = await analyze_sentiment(texts) + else: + logger.warning("sentiment_model_unavailable_using_fallback", + reason="ML model could not be loaded — using keyword fallback") + sentiments = [get_fallback_sentiment(t) for t in texts] + + # Log sentiment distribution + sent_dist = {} + scores = [s.score for s in sentiments] + for s in sentiments: + sent_dist[s.label.value] = sent_dist.get(s.label.value, 0) + 1 + logger.info("sentiment_analysis_complete", + elapsed=round(_time.time() - t0, 2), + distribution=str(sent_dist), + avg_score=round(sum(scores) / len(scores), 4) if scores else 0, + min_score=round(min(scores), 4) if scores else 0, + max_score=round(max(scores), 4) if scores else 0, + sample_label=sentiments[0].label.value if sentiments else "none", + sample_score=sentiments[0].score if sentiments else 0) + await publish_event("analysis_updates", {"job_id": job_id, "status": "processing", "progress": 0.4}) + + # Step 3: Embeddings + Topic Clustering + t0 = _time.time() + logger.info("pipeline_step", step="topic_clustering", count=len(texts)) + topic_assignments = [-1] * len(texts) + clusters = [] + topic_graph = None + reduced_embeddings = None + + if is_embedding_model_available() and len(texts) >= 5: + embeddings = await compute_embeddings(texts) + topic_assignments, clusters, reduced_embeddings = await cluster_topics( + texts, embeddings, min_cluster_size, min_samples + ) + + # Enrich clusters with sentiment/language data + for cluster in clusters: + indices = [i for i, t in enumerate(topic_assignments) if t == cluster.topic_id] + if indices: + cluster_sentiments = [sentiments[i] for i in indices] + cluster.avg_sentiment = round( + np.mean([s.score for s in cluster_sentiments]), 4 + ) + cluster.sentiment_distribution = dict( + Counter(s.label.value for s in cluster_sentiments) + ) + cluster.languages = dict( + Counter(languages[i].language for i in indices) + ) + + topic_graph = build_topic_graph(clusters, embeddings, topic_assignments) + else: + logger.warning("topic_clustering_skipped", reason="model unavailable or too few entries") + + await publish_event("analysis_updates", {"job_id": job_id, "status": "processing", "progress": 0.7}) + + # Step 4: Build analyzed entries + analyzed_entries = [] + for i, entry in enumerate(entries): + topic_id = topic_assignments[i] + topic_label = "Uncategorized" + for c in clusters: + if c.topic_id == topic_id: + topic_label = c.label + break + + analyzed_entries.append( + AnalyzedEntry( + id=entry.id or uuid.uuid4().hex[:12], + text=entry.text, + source=entry.source, + timestamp=entry.timestamp, + sentiment=sentiments[i], + language=languages[i], + topic_id=topic_id, + topic_label=topic_label, + metadata=entry.metadata, + ) + ) + + # Step 5: Sentiment trends + trends = _compute_sentiment_trends(analyzed_entries) + + # Step 6: Data quality + data_quality = analyze_data_quality(analyzed_entries) + + # Step 7: Anomaly detection + anomalies = [] + if detect_anomalies and len(sentiments) >= 20: + anomalies = run_anomaly_detection(sentiments, topic_assignments) + if anomalies: + await notify_anomalies(anomalies) + + await publish_event("analysis_updates", {"job_id": job_id, "status": "processing", "progress": 0.9}) + + # Build summary + sentiment_counts = Counter(s.label.value for s in sentiments) + dominant = max(sentiment_counts, key=sentiment_counts.get) if sentiment_counts else "neutral" + top_topics = [ + TopicInfo( + topic_id=c.topic_id, + label=c.label, + keywords=c.keywords, + size=c.size, + ) + for c in sorted(clusters, key=lambda c: c.size, reverse=True)[:5] + if c.topic_id != -1 + ] + + summary = AnalysisSummary( + total_entries=len(entries), + avg_sentiment=round(np.mean([s.score for s in sentiments]), 4), + dominant_sentiment=SentimentLabel(dominant), + num_topics=len([c for c in clusters if c.topic_id != -1]), + top_topics=top_topics, + languages_detected=list(set(l.language for l in languages if l.language != "unknown")), + date_range=_get_date_range(entries), + ) + + # Final result + result.status = AnalysisStatus.COMPLETED + result.completed_at = datetime.utcnow() + result.entries = analyzed_entries + result.topics = clusters + result.sentiment_trends = trends + result.topic_graph = topic_graph + result.data_quality = data_quality + result.anomalies = anomalies + result.summary = summary + _jobs[job_id] = result + + await publish_event("analysis_updates", { + "job_id": job_id, + "status": "completed", + "progress": 1.0, + "total_entries": len(entries), + }) + + logger.info("analysis_completed", job_id=job_id, entries=len(entries), topics=len(clusters)) + return result + + except Exception as exc: + result.status = AnalysisStatus.FAILED + _jobs[job_id] = result + await publish_event("analysis_updates", {"job_id": job_id, "status": "failed", "error": str(exc)}) + logger.error("analysis_failed", job_id=job_id, error=str(exc)) + raise + + +def _compute_sentiment_trends(entries: list[AnalyzedEntry]) -> list[SentimentTrend]: + """Compute sentiment trends over time periods.""" + dated = [e for e in entries if e.timestamp] + if not dated: + return [_single_period_trend(entries, "all")] + + dated.sort(key=lambda e: e.timestamp) + + # Determine grouping: daily if span > 7 days, else hourly + span = (dated[-1].timestamp - dated[0].timestamp).days + if span > 30: + fmt = "%Y-%m" + elif span > 7: + fmt = "%Y-%m-%d" + else: + fmt = "%Y-%m-%d %H:00" + + groups: dict[str, list[AnalyzedEntry]] = {} + for e in dated: + key = e.timestamp.strftime(fmt) + groups.setdefault(key, []).append(e) + + trends = [] + for period, group_entries in groups.items(): + trends.append(_single_period_trend(group_entries, period)) + + return trends + + +def _single_period_trend(entries: list[AnalyzedEntry], period: str) -> SentimentTrend: + scores = [e.sentiment.score for e in entries] + mean = np.mean(scores) if scores else 0.5 + std = np.std(scores) if scores else 0 + n = len(scores) + se = std / np.sqrt(n) if n > 0 else 0 + + return SentimentTrend( + period=period, + avg_sentiment=round(float(mean), 4), + count=n, + positive=sum(1 for e in entries if e.sentiment.label == SentimentLabel.POSITIVE), + negative=sum(1 for e in entries if e.sentiment.label == SentimentLabel.NEGATIVE), + neutral=sum(1 for e in entries if e.sentiment.label == SentimentLabel.NEUTRAL), + confidence_lower=round(float(max(0, mean - 1.96 * se)), 4), + confidence_upper=round(float(min(1, mean + 1.96 * se)), 4), + ) + + +def _get_date_range(entries: list[FeedbackEntry]) -> dict[str, str] | None: + dated = [e.timestamp for e in entries if e.timestamp] + if not dated: + return None + return { + "start": min(dated).isoformat(), + "end": max(dated).isoformat(), + } + + +def get_job(job_id: str) -> AnalysisResult | None: + return _jobs.get(job_id) + + +def get_all_jobs() -> list[AnalysisResult]: + return list(_jobs.values()) + + +def filter_entries( + entries: list[AnalyzedEntry], + date_from=None, + date_to=None, + sentiment_min=None, + sentiment_max=None, + topics=None, + languages=None, + sources=None, + search_text=None, +) -> list[AnalyzedEntry]: + """Apply filters to analyzed entries.""" + result = entries + + if date_from: + result = [e for e in result if e.timestamp and e.timestamp >= date_from] + if date_to: + result = [e for e in result if e.timestamp and e.timestamp <= date_to] + if sentiment_min is not None: + result = [e for e in result if e.sentiment.score >= sentiment_min] + if sentiment_max is not None: + result = [e for e in result if e.sentiment.score <= sentiment_max] + if topics: + result = [e for e in result if e.topic_id in topics] + if languages: + result = [e for e in result if e.language.language in languages] + if sources: + result = [e for e in result if e.source in sources] + if search_text: + search_lower = search_text.lower() + result = [e for e in result if search_lower in e.text.lower()] + + return result diff --git a/backend/app/services/anomaly_detection.py b/backend/app/services/anomaly_detection.py new file mode 100644 index 0000000000000000000000000000000000000000..0e0c0257e2cab6487b6d8d643f7b51c5be2477c7 --- /dev/null +++ b/backend/app/services/anomaly_detection.py @@ -0,0 +1,133 @@ +"""Anomaly detection: sentiment drift and topic spikes.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import List, Optional + +import numpy as np + +from app.core.config import settings +from app.core.logging import get_logger +from app.models.schemas import AnomalyAlert, AnomalyType, SentimentResult + +logger = get_logger(__name__) + + +def detect_sentiment_anomalies( + sentiments: list[SentimentResult], + window: Optional[int] = None, + threshold: Optional[float] = None, +) -> list[AnomalyAlert]: + """Detect when sentiment drops below rolling average - threshold * std.""" + window = window or settings.anomaly_rolling_window + threshold = threshold or settings.anomaly_sentiment_threshold + + if len(sentiments) < window: + return [] + + scores = np.array([s.score for s in sentiments]) + alerts = [] + + for i in range(window, len(scores)): + window_slice = scores[i - window : i] + mean = np.mean(window_slice) + std = np.std(window_slice) + + if std == 0: + continue + + z_score = (scores[i] - mean) / std + + if z_score < -threshold: + alerts.append( + AnomalyAlert( + id=uuid.uuid4().hex[:12], + type=AnomalyType.SENTIMENT_DROP, + severity="high" if z_score < -2 * threshold else "medium", + message=f"Sentiment dropped to {scores[i]:.3f} (rolling avg: {mean:.3f}, z-score: {z_score:.2f})", + detected_at=datetime.utcnow(), + details={ + "index": i, + "value": float(scores[i]), + "rolling_mean": float(mean), + "rolling_std": float(std), + "z_score": float(z_score), + }, + ) + ) + + return alerts + + +def detect_topic_spikes( + topic_assignments: list[int], + window: Optional[int] = None, + threshold: Optional[float] = None, +) -> list[AnomalyAlert]: + """Detect unusual spikes in topic frequency.""" + window = window or settings.anomaly_rolling_window + threshold = threshold or settings.anomaly_topic_spike_threshold + + if len(topic_assignments) < window: + return [] + + alerts = [] + unique_topics = set(topic_assignments) + + for topic_id in unique_topics: + if topic_id == -1: + continue + + occurrences = [1 if t == topic_id else 0 for t in topic_assignments] + + for i in range(window, len(occurrences)): + window_slice = occurrences[i - window : i] + mean = np.mean(window_slice) + std = np.std(window_slice) + + if std == 0: + continue + + # Check for spike in last 10% of window + recent = occurrences[max(0, i - window // 10) : i] + recent_rate = np.mean(recent) if recent else 0 + + z_score = (recent_rate - mean) / std if std > 0 else 0 + + if z_score > threshold: + alerts.append( + AnomalyAlert( + id=uuid.uuid4().hex[:12], + type=AnomalyType.TOPIC_SPIKE, + severity="high" if z_score > 2 * threshold else "medium", + message=f"Topic {topic_id} spike detected (rate: {recent_rate:.3f}, avg: {mean:.3f})", + detected_at=datetime.utcnow(), + details={ + "topic_id": topic_id, + "recent_rate": float(recent_rate), + "rolling_mean": float(mean), + "z_score": float(z_score), + }, + ) + ) + break # One alert per topic + + return alerts + + +def run_anomaly_detection( + sentiments: list[SentimentResult], + topic_assignments: list[int], + thresholds: Optional[dict] = None, +) -> list[AnomalyAlert]: + """Run all anomaly detection checks.""" + window = thresholds.get("rolling_window") if thresholds else None + sent_thresh = thresholds.get("sentiment_threshold") if thresholds else None + topic_thresh = thresholds.get("topic_spike_threshold") if thresholds else None + + alerts = [] + alerts.extend(detect_sentiment_anomalies(sentiments, window, sent_thresh)) + alerts.extend(detect_topic_spikes(topic_assignments, window, topic_thresh)) + return alerts diff --git a/backend/app/services/data_quality.py b/backend/app/services/data_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..969b5522806e8075232925639e1e81367d074d55 --- /dev/null +++ b/backend/app/services/data_quality.py @@ -0,0 +1,62 @@ +"""Data quality analysis: low confidence, mixed language, duplicate detection.""" + +from __future__ import annotations + +from collections import Counter +from typing import List + +from app.models.schemas import AnalyzedEntry, DataQualityReport + + +def analyze_data_quality(entries: list[AnalyzedEntry]) -> DataQualityReport: + """Generate data quality report from analyzed entries.""" + if not entries: + return DataQualityReport( + total_entries=0, + low_confidence_count=0, + low_confidence_entries=[], + mixed_language_count=0, + mixed_language_entries=[], + duplicate_count=0, + duplicate_entries=[], + avg_confidence=0.0, + language_distribution={}, + ) + + # Low confidence predictions (< 0.5) + low_conf = [e for e in entries if e.sentiment.confidence < 0.5] + low_conf_ids = [e.id for e in low_conf[:50]] + + # Mixed language: entries where detected language differs from majority + lang_counts = Counter(e.language.language for e in entries) + majority_lang = lang_counts.most_common(1)[0][0] if lang_counts else "unknown" + mixed_lang = [ + e for e in entries + if e.language.language != majority_lang and e.language.language != "unknown" + ] + mixed_lang_ids = [e.id for e in mixed_lang[:50]] + + # Duplicate detection via text similarity (exact and near-duplicates) + seen_texts: dict[str, str] = {} + duplicate_ids = [] + for e in entries: + normalized = e.text.strip().lower()[:200] + if normalized in seen_texts: + duplicate_ids.append(e.id) + else: + seen_texts[normalized] = e.id + + # Average confidence + avg_conf = sum(e.sentiment.confidence for e in entries) / len(entries) + + return DataQualityReport( + total_entries=len(entries), + low_confidence_count=len(low_conf), + low_confidence_entries=low_conf_ids, + mixed_language_count=len(mixed_lang), + mixed_language_entries=mixed_lang_ids, + duplicate_count=len(duplicate_ids), + duplicate_entries=duplicate_ids[:50], + avg_confidence=round(avg_conf, 4), + language_distribution=dict(lang_counts), + ) diff --git a/backend/app/services/export.py b/backend/app/services/export.py new file mode 100644 index 0000000000000000000000000000000000000000..790ec37a86dc93fb66c47f28e294a757406066ac --- /dev/null +++ b/backend/app/services/export.py @@ -0,0 +1,131 @@ +"""Export service: CSV, JSON, PDF report generation.""" + +from __future__ import annotations + +import csv +import io +import json +from typing import List + +from app.core.logging import get_logger +from app.models.schemas import AnalyzedEntry, ExportFormat + +logger = get_logger(__name__) + + +def export_csv(entries: list[AnalyzedEntry]) -> bytes: + output = io.StringIO() + writer = csv.writer(output) + writer.writerow([ + "id", "text", "source", "timestamp", "sentiment_label", + "sentiment_score", "confidence", "language", "topic_id", "topic_label", + ]) + for e in entries: + writer.writerow([ + e.id, e.text, e.source or "", e.timestamp or "", + e.sentiment.label.value, e.sentiment.score, e.sentiment.confidence, + e.language.language, e.topic_id, e.topic_label, + ]) + return output.getvalue().encode("utf-8") + + +def export_json(entries: list[AnalyzedEntry]) -> bytes: + data = [ + { + "id": e.id, + "text": e.text, + "source": e.source, + "timestamp": e.timestamp.isoformat() if e.timestamp else None, + "sentiment": { + "label": e.sentiment.label.value, + "score": e.sentiment.score, + "confidence": e.sentiment.confidence, + }, + "language": { + "language": e.language.language, + "confidence": e.language.confidence, + }, + "topic_id": e.topic_id, + "topic_label": e.topic_label, + } + for e in entries + ] + return json.dumps(data, indent=2, default=str).encode("utf-8") + + +def export_pdf(entries: list[AnalyzedEntry], summary: dict | None = None) -> bytes: + """Generate a PDF report using reportlab.""" + try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4, letter + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import inch + from reportlab.platypus import ( + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + except ImportError: + logger.error("reportlab_not_installed") + raise ImportError( + "PDF export requires reportlab. Install it with: pip install reportlab" + ) + + buffer = io.BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=A4) + styles = getSampleStyleSheet() + elements = [] + + # Title + title_style = ParagraphStyle("Title", parent=styles["Title"], fontSize=18) + elements.append(Paragraph("Topic Analysis Report", title_style)) + elements.append(Spacer(1, 12)) + + # Summary + if summary: + elements.append(Paragraph("Summary", styles["Heading2"])) + for key, val in summary.items(): + elements.append(Paragraph(f"{key}: {val}", styles["Normal"])) + elements.append(Spacer(1, 12)) + + # Data table + elements.append(Paragraph("Analysis Results", styles["Heading2"])) + table_data = [["ID", "Sentiment", "Score", "Language", "Topic"]] + + for e in entries[:500]: # Limit for PDF + table_data.append([ + e.id[:8], + e.sentiment.label.value, + f"{e.sentiment.score:.2f}", + e.language.language, + e.topic_label[:30], + ]) + + table = Table(table_data, colWidths=[60, 70, 50, 60, 180]) + table.setStyle(TableStyle([ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("ALIGN", (0, 0), (-1, -1), "LEFT"), + ("FONTSIZE", (0, 0), (-1, 0), 10), + ("FONTSIZE", (0, 1), (-1, -1), 8), + ("BOTTOMPADDING", (0, 0), (-1, 0), 8), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f5f5f5")]), + ])) + + elements.append(table) + doc.build(elements) + return buffer.getvalue() + + +def export_entries(entries: list[AnalyzedEntry], fmt: ExportFormat, summary: dict | None = None) -> bytes: + if fmt == ExportFormat.CSV: + return export_csv(entries) + elif fmt == ExportFormat.JSON: + return export_json(entries) + elif fmt == ExportFormat.PDF: + return export_pdf(entries, summary) + else: + raise ValueError(f"Unsupported export format: {fmt}") diff --git a/backend/app/services/file_processing.py b/backend/app/services/file_processing.py new file mode 100644 index 0000000000000000000000000000000000000000..bed1c121d722f547a6b8b11324a7eac1f4178d2e --- /dev/null +++ b/backend/app/services/file_processing.py @@ -0,0 +1,184 @@ +"""File processing service: handles CSV, JSON, Excel, ZIP with chunked uploads.""" + +from __future__ import annotations + +import io +import json +import uuid +import zipfile +from pathlib import Path +from typing import List + +import pandas as pd + +from app.core.config import settings +from app.core.logging import get_logger +from app.models.schemas import FeedbackEntry + +logger = get_logger(__name__) + +SUPPORTED_EXTENSIONS = {".csv", ".json", ".xlsx", ".xls", ".zip"} +TEXT_COLUMN_CANDIDATES = [ + "text", "content", "message", "body", "feedback", "review", + "comment", "description", "note", "summary", "title", + "Text", "Content", "Message", "Body", "Feedback", "Review", +] +TIMESTAMP_COLUMN_CANDIDATES = [ + "timestamp", "date", "created_at", "created", "time", "datetime", + "Timestamp", "Date", "Created", "CreatedAt", +] +SOURCE_COLUMN_CANDIDATES = [ + "source", "channel", "platform", "origin", "category", "type", + "Source", "Channel", "Platform", +] + + +def _find_column(df: pd.DataFrame, candidates: list[str]) -> str | None: + for col in candidates: + if col in df.columns: + return col + for col in df.columns: + for candidate in candidates: + if candidate.lower() in col.lower(): + return col + return None + + +def _df_to_entries(df: pd.DataFrame, source: str | None = None) -> list[FeedbackEntry]: + text_col = _find_column(df, TEXT_COLUMN_CANDIDATES) + if not text_col: + if len(df.columns) == 1: + text_col = df.columns[0] + else: + raise ValueError( + f"No text column found. Expected one of: {TEXT_COLUMN_CANDIDATES}. " + f"Found columns: {list(df.columns)}" + ) + + ts_col = _find_column(df, TIMESTAMP_COLUMN_CANDIDATES) + src_col = _find_column(df, SOURCE_COLUMN_CANDIDATES) + + entries = [] + other_cols = [c for c in df.columns if c not in {text_col, ts_col, src_col}] + + for _, row in df.iterrows(): + text = str(row[text_col]).strip() + if not text or text == "nan": + continue + + ts = None + if ts_col and pd.notna(row.get(ts_col)): + try: + ts = pd.to_datetime(row[ts_col]) + except Exception: + pass + + src = source + if src_col and pd.notna(row.get(src_col)): + src = str(row[src_col]) + + metadata = {} + for col in other_cols: + val = row.get(col) + if pd.notna(val): + metadata[col] = str(val) if not isinstance(val, (int, float, bool)) else val + + entries.append( + FeedbackEntry( + id=uuid.uuid4().hex[:12], + text=text, + source=src, + timestamp=ts, + metadata=metadata if metadata else None, + ) + ) + + return entries + + +def parse_csv(content: bytes, source: str | None = None) -> list[FeedbackEntry]: + for encoding in ("utf-8", "latin-1", "cp1252"): + try: + df = pd.read_csv(io.BytesIO(content), encoding=encoding) + return _df_to_entries(df, source) + except UnicodeDecodeError: + continue + raise ValueError("Unable to decode CSV file with supported encodings") + + +def parse_json(content: bytes, source: str | None = None) -> list[FeedbackEntry]: + data = json.loads(content.decode("utf-8")) + + if isinstance(data, list): + if all(isinstance(item, str) for item in data): + return [ + FeedbackEntry(id=uuid.uuid4().hex[:12], text=item, source=source) + for item in data + if item.strip() + ] + df = pd.DataFrame(data) + return _df_to_entries(df, source) + elif isinstance(data, dict): + if "data" in data: + df = pd.DataFrame(data["data"]) + elif "entries" in data: + df = pd.DataFrame(data["entries"]) + elif "results" in data: + df = pd.DataFrame(data["results"]) + else: + df = pd.DataFrame([data]) + return _df_to_entries(df, source) + + raise ValueError("Unsupported JSON structure") + + +def parse_excel(content: bytes, source: str | None = None) -> list[FeedbackEntry]: + df = pd.read_excel(io.BytesIO(content), engine="openpyxl") + return _df_to_entries(df, source) + + +def parse_zip(content: bytes, source: str | None = None) -> list[FeedbackEntry]: + all_entries = [] + with zipfile.ZipFile(io.BytesIO(content)) as zf: + for name in zf.namelist(): + if name.startswith("__MACOSX") or name.startswith("."): + continue + ext = Path(name).suffix.lower() + inner = zf.read(name) + file_source = source or Path(name).stem + try: + if ext == ".csv": + all_entries.extend(parse_csv(inner, file_source)) + elif ext == ".json": + all_entries.extend(parse_json(inner, file_source)) + elif ext in (".xlsx", ".xls"): + all_entries.extend(parse_excel(inner, file_source)) + else: + logger.warning("skipping_unsupported_file_in_zip", filename=name) + except Exception as exc: + logger.error("error_processing_zip_entry", filename=name, error=str(exc)) + return all_entries + + +def parse_file(content: bytes, filename: str, source: str | None = None) -> list[FeedbackEntry]: + ext = Path(filename).suffix.lower() + if ext not in SUPPORTED_EXTENSIONS: + raise ValueError(f"Unsupported file format: {ext}. Supported: {SUPPORTED_EXTENSIONS}") + + parsers = { + ".csv": parse_csv, + ".json": parse_json, + ".xlsx": parse_excel, + ".xls": parse_excel, + ".zip": parse_zip, + } + + return parsers[ext](content, source) + + +async def save_upload(content: bytes, filename: str) -> Path: + upload_dir = settings.upload_path + safe_name = f"{uuid.uuid4().hex[:8]}_{Path(filename).name}" + file_path = upload_dir / safe_name + file_path.write_bytes(content) + return file_path diff --git a/backend/app/services/language_detection.py b/backend/app/services/language_detection.py new file mode 100644 index 0000000000000000000000000000000000000000..34ab07da53099b57639c56b580eb2239e673383d --- /dev/null +++ b/backend/app/services/language_detection.py @@ -0,0 +1,58 @@ +"""Language detection with langdetect primary and cld3 fallback.""" + +from __future__ import annotations + +from app.core.logging import get_logger +from app.models.schemas import LanguageResult + +logger = get_logger(__name__) + + +def detect_language(text: str) -> LanguageResult: + """Detect language using langdetect with cld3 fallback.""" + if not text or len(text.strip()) < 3: + return LanguageResult(language="unknown", confidence=0.0, method="none") + + # Primary: langdetect + try: + from langdetect import DetectorFactory, detect_langs + + DetectorFactory.seed = 42 + results = detect_langs(text) + if results: + top = results[0] + return LanguageResult( + language=str(top.lang), + confidence=round(top.prob, 4), + method="langdetect", + ) + except Exception as exc: + logger.debug("langdetect_failed", error=str(exc)) + + # Fallback: cld3 + try: + import cld3 + + result = cld3.get_language(text) + if result and result.is_reliable: + return LanguageResult( + language=result.language, + confidence=round(result.probability, 4), + method="cld3", + ) + elif result: + return LanguageResult( + language=result.language, + confidence=round(result.probability, 4), + method="cld3", + ) + except ImportError: + logger.warning("cld3_not_available", detail="Install pycld3 for fallback detection") + except Exception as exc: + logger.debug("cld3_failed", error=str(exc)) + + return LanguageResult(language="unknown", confidence=0.0, method="none") + + +def detect_languages_batch(texts: list[str]) -> list[LanguageResult]: + return [detect_language(t) for t in texts] diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000000000000000000000000000000000000..9b2f6a4b4ab3362559d8030d1fae21475e966c17 --- /dev/null +++ b/backend/app/services/notifications.py @@ -0,0 +1,92 @@ +"""Notification service for anomaly alerts (email + Slack webhook).""" + +from __future__ import annotations + +import smtplib +from email.mime.text import MIMEText +from typing import List + +import httpx + +from app.core.config import settings +from app.core.logging import get_logger +from app.models.schemas import AnomalyAlert + +logger = get_logger(__name__) + + +async def send_slack_notification(alerts: list[AnomalyAlert]) -> bool: + if not settings.slack_webhook_url: + logger.debug("slack_webhook_not_configured") + return False + + blocks = [] + for alert in alerts[:10]: + emoji = "🔴" if alert.severity == "high" else "🟡" + blocks.append( + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"{emoji} *{alert.type.value}* ({alert.severity})\n{alert.message}", + }, + } + ) + + payload = { + "text": f"🚨 {len(alerts)} anomaly alert(s) detected", + "blocks": [ + { + "type": "header", + "text": {"type": "plain_text", "text": f"🚨 {len(alerts)} Anomaly Alert(s)"}, + }, + *blocks, + ], + } + + try: + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.post(settings.slack_webhook_url, json=payload) + resp.raise_for_status() + logger.info("slack_notification_sent", alert_count=len(alerts)) + return True + except Exception as exc: + logger.error("slack_notification_failed", error=str(exc)) + return False + + +async def send_email_notification(alerts: list[AnomalyAlert]) -> bool: + if not all([settings.smtp_host, settings.notification_email_from, settings.notification_email_to]): + logger.debug("email_notification_not_configured") + return False + + body_lines = [] + for alert in alerts: + body_lines.append(f"[{alert.severity.upper()}] {alert.type.value}: {alert.message}") + body_lines.append(f" Detected at: {alert.detected_at.isoformat()}") + body_lines.append("") + + msg = MIMEText("\n".join(body_lines)) + msg["Subject"] = f"Topic Analysis: {len(alerts)} anomaly alert(s)" + msg["From"] = settings.notification_email_from + msg["To"] = settings.notification_email_to + + try: + with smtplib.SMTP(settings.smtp_host, settings.smtp_port) as server: + server.starttls() + if settings.smtp_user: + server.login(settings.smtp_user, settings.smtp_password) + server.send_message(msg) + logger.info("email_notification_sent", alert_count=len(alerts)) + return True + except Exception as exc: + logger.error("email_notification_failed", error=str(exc)) + return False + + +async def notify_anomalies(alerts: list[AnomalyAlert]) -> None: + if not alerts: + return + + await send_slack_notification(alerts) + await send_email_notification(alerts) diff --git a/backend/app/services/redis_client.py b/backend/app/services/redis_client.py new file mode 100644 index 0000000000000000000000000000000000000000..4b784e829c40fed0115797bac72cbf140e8838ea --- /dev/null +++ b/backend/app/services/redis_client.py @@ -0,0 +1,83 @@ +"""Redis client for caching and SSE broadcast.""" + +from __future__ import annotations + +import json +from typing import Any, AsyncIterator, Optional + +import redis.asyncio as aioredis + +from app.core.config import settings +from app.core.logging import get_logger + +logger = get_logger(__name__) + +_redis: Optional[aioredis.Redis] = None + + +async def get_redis() -> aioredis.Redis: + global _redis + if _redis is None: + _redis = aioredis.from_url( + settings.redis_url, + decode_responses=True, + max_connections=20, + ) + return _redis + + +async def close_redis() -> None: + global _redis + if _redis: + await _redis.aclose() + _redis = None + + +async def cache_get(key: str) -> Optional[Any]: + try: + r = await get_redis() + val = await r.get(f"cache:{key}") + return json.loads(val) if val else None + except Exception as exc: + logger.warning("redis_cache_get_failed", key=key, error=str(exc)) + return None + + +async def cache_set(key: str, value: Any, ttl: int = 300) -> None: + try: + r = await get_redis() + await r.setex(f"cache:{key}", ttl, json.dumps(value, default=str)) + except Exception as exc: + logger.warning("redis_cache_set_failed", key=key, error=str(exc)) + + +async def publish_event(channel: str, data: dict) -> None: + try: + r = await get_redis() + await r.publish(channel, json.dumps(data, default=str)) + except Exception as exc: + logger.warning("redis_publish_failed", channel=channel, error=str(exc)) + + +async def subscribe_events(channel: str) -> AsyncIterator[dict]: + r = await get_redis() + pubsub = r.pubsub() + await pubsub.subscribe(channel) + try: + async for message in pubsub.listen(): + if message["type"] == "message": + try: + yield json.loads(message["data"]) + except json.JSONDecodeError: + continue + finally: + await pubsub.unsubscribe(channel) + await pubsub.aclose() + + +async def check_redis_health() -> bool: + try: + r = await get_redis() + return await r.ping() + except Exception: + return False diff --git a/backend/app/services/sentiment.py b/backend/app/services/sentiment.py new file mode 100644 index 0000000000000000000000000000000000000000..5de2c52a90644ecabb31528895ee9416ce37a230 --- /dev/null +++ b/backend/app/services/sentiment.py @@ -0,0 +1,180 @@ +"""Sentiment analysis using cardiffnlp/twitter-xlm-roberta-base-sentiment.""" + +from __future__ import annotations + +import asyncio +import time +from concurrent.futures import ThreadPoolExecutor +from typing import List, Optional + +from app.core.config import settings +from app.core.logging import get_logger +from app.models.schemas import SentimentLabel, SentimentResult + +logger = get_logger(__name__) + +_model = None +_tokenizer = None +_executor = ThreadPoolExecutor(max_workers=2) + +LABEL_MAP = { + "negative": SentimentLabel.NEGATIVE, + "neutral": SentimentLabel.NEUTRAL, + "positive": SentimentLabel.POSITIVE, + "LABEL_0": SentimentLabel.NEGATIVE, + "LABEL_1": SentimentLabel.NEUTRAL, + "LABEL_2": SentimentLabel.POSITIVE, +} + + +def _load_model(): + global _model, _tokenizer + if _model is not None: + return + + try: + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + model_name = settings.sentiment_model + logger.info("loading_sentiment_model", model=model_name) + t0 = time.time() + + _tokenizer = AutoTokenizer.from_pretrained( + model_name, + cache_dir=settings.model_cache_dir, + ) + logger.info("tokenizer_loaded", model=model_name, elapsed=round(time.time() - t0, 2)) + + _model = AutoModelForSequenceClassification.from_pretrained( + model_name, + cache_dir=settings.model_cache_dir, + ) + _model.eval() + label_config = getattr(_model.config, "id2label", {}) + logger.info( + "sentiment_model_loaded", + model=model_name, + elapsed=round(time.time() - t0, 2), + model_labels=str(label_config), + num_labels=getattr(_model.config, "num_labels", "unknown"), + ) + except Exception as exc: + logger.error("sentiment_model_load_failed", error=str(exc), exc_type=type(exc).__name__) + raise + + +def _predict_batch_sync(texts: list[str]) -> list[SentimentResult]: + import torch + from scipy.special import softmax + + _load_model() + + results = [] + batch_size = 32 + t0 = time.time() + + for i in range(0, len(texts), batch_size): + batch = texts[i : i + batch_size] + truncated = [t[:512] for t in batch] + + inputs = _tokenizer( + truncated, + padding=True, + truncation=True, + max_length=512, + return_tensors="pt", + ) + + with torch.no_grad(): + outputs = _model(**inputs) + + scores = outputs.logits.detach().numpy() + + for j, score_row in enumerate(scores): + probs = softmax(score_row) + label_idx = int(probs.argmax()) + # Use model's own id2label mapping (0=negative, 1=neutral, 2=positive) + id2label = {0: SentimentLabel.NEGATIVE, 1: SentimentLabel.NEUTRAL, 2: SentimentLabel.POSITIVE} + label = id2label.get(label_idx, SentimentLabel.NEUTRAL) + confidence = float(probs[label_idx]) + + # Sentiment score: -1 (negative) to +1 (positive) + sentiment_score = float(probs[2] - probs[0]) + + results.append( + SentimentResult( + label=label, + score=round(max(0, min(1, (sentiment_score + 1) / 2)), 4), + confidence=round(confidence, 4), + ) + ) + + # Log first batch for debugging + if i == 0 and len(results) > 0: + sample = results[0] + logger.info( + "sentiment_first_batch_sample", + text_preview=truncated[0][:80], + label=sample.label.value, + score=sample.score, + confidence=sample.confidence, + ) + + elapsed = round(time.time() - t0, 2) + logger.info( + "sentiment_batch_complete", + total_texts=len(texts), + elapsed_seconds=elapsed, + texts_per_second=round(len(texts) / max(elapsed, 0.001), 1), + ) + + return results + + +async def analyze_sentiment(texts: list[str]) -> list[SentimentResult]: + """Analyze sentiment for a batch of texts asynchronously.""" + logger.info("analyze_sentiment_called", count=len(texts), using="ml_model") + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_executor, _predict_batch_sync, texts) + + +def analyze_sentiment_sync(texts: list[str]) -> list[SentimentResult]: + """Synchronous sentiment analysis.""" + logger.info("analyze_sentiment_sync_called", count=len(texts)) + return _predict_batch_sync(texts) + + +_models_available: Optional[bool] = None + + +def is_model_available() -> bool: + """Check if ML model is available. Re-checks on each call until successful.""" + global _models_available + if _models_available is True: + return True + # Always retry if previously failed — deps may have been installed since last check + try: + _load_model() + _models_available = True + logger.info("model_availability_check", available=True) + except Exception as exc: + _models_available = False + logger.warning("model_availability_check", available=False, error=str(exc)) + return _models_available + + +def get_fallback_sentiment(text: str) -> SentimentResult: + """Simple keyword-based fallback when ML model unavailable.""" + logger.debug("using_fallback_sentiment", text_preview=text[:60]) + text_lower = text.lower() + positive_words = {"good", "great", "excellent", "love", "amazing", "happy", "best", "wonderful", "fantastic"} + negative_words = {"bad", "terrible", "awful", "hate", "worst", "horrible", "poor", "disappointing", "angry"} + + pos = sum(1 for w in text_lower.split() if w in positive_words) + neg = sum(1 for w in text_lower.split() if w in negative_words) + + if pos > neg: + return SentimentResult(label=SentimentLabel.POSITIVE, score=0.7, confidence=0.3) + elif neg > pos: + return SentimentResult(label=SentimentLabel.NEGATIVE, score=0.3, confidence=0.3) + return SentimentResult(label=SentimentLabel.NEUTRAL, score=0.5, confidence=0.3) diff --git a/backend/app/services/topic_clustering.py b/backend/app/services/topic_clustering.py new file mode 100644 index 0000000000000000000000000000000000000000..f5ef3543e1c8efd3a4f5bf0b5e727d18078474a9 --- /dev/null +++ b/backend/app/services/topic_clustering.py @@ -0,0 +1,220 @@ +"""Topic clustering using BERTopic with HDBSCAN + UMAP.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import List, Optional, Tuple + +import numpy as np + +from app.core.config import settings +from app.core.logging import get_logger +from app.models.schemas import TopicCluster, TopicGraph, TopicInfo, TopicLink + +logger = get_logger(__name__) + +_embedding_model = None +_executor = ThreadPoolExecutor(max_workers=2) + + +def _load_embedding_model(): + global _embedding_model + if _embedding_model is not None: + return + + try: + from sentence_transformers import SentenceTransformer + + model_name = settings.embedding_model + logger.info("loading_embedding_model", model=model_name) + _embedding_model = SentenceTransformer( + model_name, + cache_folder=settings.model_cache_dir, + ) + logger.info("embedding_model_loaded", model=model_name) + except Exception as exc: + logger.error("embedding_model_load_failed", error=str(exc)) + raise + + +def _compute_embeddings(texts: list[str]) -> np.ndarray: + _load_embedding_model() + return _embedding_model.encode( + texts, + show_progress_bar=False, + batch_size=64, + normalize_embeddings=True, + ) + + +def _adaptive_params(n_docs: int) -> dict: + """Adapt HDBSCAN/UMAP parameters to data volume.""" + if n_docs < 20: + return {"min_cluster_size": 2, "min_samples": 1, "n_neighbors": 3, "n_components": 2} + elif n_docs < 100: + return {"min_cluster_size": 3, "min_samples": 2, "n_neighbors": 5, "n_components": 3} + elif n_docs < 500: + return {"min_cluster_size": 5, "min_samples": 3, "n_neighbors": 10, "n_components": 5} + elif n_docs < 2000: + return {"min_cluster_size": 10, "min_samples": 5, "n_neighbors": 15, "n_components": 5} + else: + return {"min_cluster_size": 15, "min_samples": 8, "n_neighbors": 15, "n_components": 10} + + +def _cluster_topics_sync( + texts: list[str], + embeddings: Optional[np.ndarray] = None, + min_cluster_size: Optional[int] = None, + min_samples: Optional[int] = None, +) -> Tuple[list[int], list[TopicCluster], Optional[np.ndarray]]: + from bertopic import BERTopic + from hdbscan import HDBSCAN + from sklearn.feature_extraction.text import CountVectorizer + from umap import UMAP + + if embeddings is None: + embeddings = _compute_embeddings(texts) + + params = _adaptive_params(len(texts)) + mcs = min_cluster_size or params["min_cluster_size"] + ms = min_samples or params["min_samples"] + + umap_model = UMAP( + n_neighbors=params["n_neighbors"], + n_components=params["n_components"], + min_dist=0.0, + metric="cosine", + random_state=42, + ) + + hdbscan_model = HDBSCAN( + min_cluster_size=mcs, + min_samples=ms, + metric="euclidean", + prediction_data=True, + ) + + vectorizer = CountVectorizer( + stop_words="english", + max_features=10000, + ngram_range=(1, 2), + ) + + topic_model = BERTopic( + umap_model=umap_model, + hdbscan_model=hdbscan_model, + vectorizer_model=vectorizer, + calculate_probabilities=True, + verbose=False, + ) + + topics, probs = topic_model.fit_transform(texts, embeddings) + + topic_info = topic_model.get_topic_info() + clusters = [] + + for _, row in topic_info.iterrows(): + tid = int(row["Topic"]) + if tid == -1: + label = "Uncategorized" + keywords = [] + else: + topic_words = topic_model.get_topic(tid) + keywords = [w for w, _ in topic_words[:10]] if topic_words else [] + label = " | ".join(keywords[:3]) if keywords else f"Topic {tid}" + + indices = [i for i, t in enumerate(topics) if t == tid] + rep_docs = [texts[i][:200] for i in indices[:3]] + + clusters.append( + TopicCluster( + topic_id=tid, + label=label, + keywords=keywords, + size=int(row.get("Count", len(indices))), + avg_sentiment=0.0, + sentiment_distribution={"positive": 0, "negative": 0, "neutral": 0}, + languages={}, + representative_docs=rep_docs, + ) + ) + + # Get 2D coordinates for visualization + reduced = None + if len(texts) > 2: + try: + umap_2d = UMAP(n_components=2, random_state=42, metric="cosine") + reduced = umap_2d.fit_transform(embeddings) + except Exception: + pass + + return topics, clusters, reduced + + +def build_topic_graph(clusters: list[TopicCluster], embeddings: np.ndarray, topics: list[int]) -> TopicGraph: + """Build force-directed graph from topic clusters.""" + from sklearn.metrics.pairwise import cosine_similarity + + unique_topics = list({c.topic_id for c in clusters if c.topic_id != -1}) + links = [] + + if len(unique_topics) > 1: + centroids = [] + for tid in unique_topics: + indices = [i for i, t in enumerate(topics) if t == tid] + if indices: + centroid = embeddings[indices].mean(axis=0) + centroids.append(centroid) + else: + centroids.append(np.zeros(embeddings.shape[1])) + + sim_matrix = cosine_similarity(np.array(centroids)) + + for i, t1 in enumerate(unique_topics): + for j, t2 in enumerate(unique_topics): + if i < j and sim_matrix[i][j] > 0.1: + links.append( + TopicLink( + source=t1, + target=t2, + weight=round(float(sim_matrix[i][j]), 4), + ) + ) + + return TopicGraph(nodes=clusters, links=links) + + +async def cluster_topics( + texts: list[str], + embeddings: Optional[np.ndarray] = None, + min_cluster_size: Optional[int] = None, + min_samples: Optional[int] = None, +) -> Tuple[list[int], list[TopicCluster], Optional[np.ndarray]]: + loop = asyncio.get_event_loop() + return await loop.run_in_executor( + _executor, _cluster_topics_sync, texts, embeddings, min_cluster_size, min_samples + ) + + +async def compute_embeddings(texts: list[str]) -> np.ndarray: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_executor, _compute_embeddings, texts) + + +_embedding_available: Optional[bool] = None + + +def is_embedding_model_available() -> bool: + """Check if embedding model is available. Re-checks on each call until successful.""" + global _embedding_available + if _embedding_available is True: + return True + try: + _load_embedding_model() + _embedding_available = True + logger.info("embedding_model_availability", available=True) + except Exception as exc: + _embedding_available = False + logger.warning("embedding_model_availability", available=False, error=str(exc)) + return _embedding_available diff --git a/backend/app/utils/__init__.py b/backend/app/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..b9d738cd811de4530f6ef0ffaaec16473e7ae75e --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,12 @@ +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..7553e37e724aa6a6a8bf73ff25596b738def42a1 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,54 @@ +# Backend dependencies — pinned versions +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-multipart==0.0.20 +aiofiles==24.1.0 +httpx==0.28.1 +redis[hiredis]==5.2.1 +sse-starlette==2.2.1 + +# ML / NLP +torch==2.5.1 +transformers==4.47.1 +sentence-transformers==3.3.1 +bertopic==0.16.4 +hdbscan==0.8.40 +umap-learn==0.5.7 +scikit-learn==1.6.1 +langdetect==1.0.9 +pycld3==0.22 + +# Data handling +pandas==2.2.3 +openpyxl==3.1.5 +xlrd==2.0.1 +numpy==1.26.4 + +# Export +reportlab==4.2.5 +weasyprint==63.1 + +# Observability +opentelemetry-api==1.29.0 +opentelemetry-sdk==1.29.0 +opentelemetry-instrumentation-fastapi==0.50b0 +opentelemetry-exporter-otlp==1.29.0 +prometheus-client==0.21.1 +prometheus-fastapi-instrumentator==7.0.2 +structlog==24.4.0 + +# Security +python-jose[cryptography]==3.3.0 +slowapi==0.1.9 + +# Testing +pytest==8.3.4 +pytest-asyncio==0.25.0 +pytest-cov==6.0.0 +httpx==0.28.1 + +# Utilities +python-dotenv==1.0.1 +tenacity==9.0.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..df6cdb2ee5d828e0d05e07fc70ac3f76479aeb77 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,73 @@ +"""Pytest configuration and shared fixtures.""" + +from __future__ import annotations + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def mock_env(): + os.environ["ALLOWED_API_KEYS"] = '["test-key"]' + os.environ["REDIS_URL"] = "redis://localhost:6379/0" + os.environ["APP_ENV"] = "testing" + os.environ["LOG_FORMAT"] = "console" + os.environ["CORS_ORIGINS"] = '["http://localhost:3000"]' + yield + + +@pytest.fixture +def api_headers(): + return {"X-API-Key": "test-key"} + + +@pytest.fixture +def mock_redis(): + with patch("app.services.redis_client.get_redis") as mock: + redis_mock = AsyncMock() + redis_mock.ping.return_value = True + redis_mock.get.return_value = None + redis_mock.setex.return_value = True + redis_mock.publish.return_value = 1 + mock.return_value = redis_mock + yield redis_mock + + +@pytest.fixture +def mock_sentiment(): + with patch("app.services.sentiment._load_model"): + with patch("app.services.sentiment.is_model_available", return_value=False): + yield + + +@pytest.fixture +def mock_embeddings(): + with patch("app.services.topic_clustering._load_embedding_model"): + with patch("app.services.topic_clustering.is_embedding_model_available", return_value=False): + yield + + +@pytest.fixture +def client(mock_redis, mock_sentiment, mock_embeddings): + from app.main import app + with TestClient(app) as c: + yield c + + +@pytest.fixture +def sample_csv_content(): + return b"text,source,timestamp\nGreat product!,survey,2024-01-01\nTerrible service,email,2024-01-02\nOkay experience,chat,2024-01-03\n" + + +@pytest.fixture +def sample_json_content(): + import json + data = [ + {"text": "Love this product!", "source": "app", "timestamp": "2024-01-01"}, + {"text": "Not happy with the service", "source": "email", "timestamp": "2024-01-02"}, + {"text": "It works fine", "source": "web", "timestamp": "2024-01-03"}, + ] + return json.dumps(data).encode("utf-8") diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..b98f76ded2ab2832d216f5152fd6208e37e24edc --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,118 @@ +"""Tests for API endpoints.""" + +from __future__ import annotations + +import io +import json +from unittest.mock import AsyncMock, patch + +import pytest + + +class TestHealthEndpoints: + def test_health(self, client, api_headers): + resp = client.get("/health") + assert resp.status_code == 200 + data = resp.json() + assert data["status"] in ("healthy", "degraded") + assert "version" in data + assert "uptime_seconds" in data + + def test_liveness(self, client): + resp = client.get("/health/live") + assert resp.status_code == 200 + assert resp.json()["status"] == "alive" + + +class TestUploadEndpoints: + def test_upload_csv(self, client, api_headers, sample_csv_content): + with patch("app.api.analysis.run_analysis", new_callable=AsyncMock) as mock_run: + mock_run.return_value = None + resp = client.post( + "/api/v1/upload", + files={"file": ("test.csv", io.BytesIO(sample_csv_content), "text/csv")}, + headers=api_headers, + ) + assert resp.status_code == 200 + data = resp.json() + assert "job_id" in data + assert data["status"] == "pending" + + def test_upload_json(self, client, api_headers, sample_json_content): + with patch("app.api.analysis.run_analysis", new_callable=AsyncMock): + resp = client.post( + "/api/v1/upload", + files={"file": ("test.json", io.BytesIO(sample_json_content), "application/json")}, + headers=api_headers, + ) + assert resp.status_code == 200 + + def test_upload_unsupported_format(self, client, api_headers): + resp = client.post( + "/api/v1/upload", + files={"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")}, + headers=api_headers, + ) + assert resp.status_code == 400 + assert "Unsupported" in resp.json()["detail"] + + def test_upload_no_api_key(self, client, sample_csv_content): + resp = client.post( + "/api/v1/upload", + files={"file": ("test.csv", io.BytesIO(sample_csv_content), "text/csv")}, + ) + assert resp.status_code == 403 + + def test_upload_invalid_api_key(self, client, sample_csv_content): + resp = client.post( + "/api/v1/upload", + files={"file": ("test.csv", io.BytesIO(sample_csv_content), "text/csv")}, + headers={"X-API-Key": "wrong-key"}, + ) + assert resp.status_code == 403 + + def test_upload_empty_file(self, client, api_headers): + resp = client.post( + "/api/v1/upload", + files={"file": ("test.csv", io.BytesIO(b"text\n"), "text/csv")}, + headers=api_headers, + ) + assert resp.status_code == 400 + + +class TestJobEndpoints: + def test_list_jobs(self, client, api_headers): + resp = client.get("/api/v1/jobs", headers=api_headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + def test_get_nonexistent_job(self, client, api_headers): + resp = client.get("/api/v1/jobs/nonexistent", headers=api_headers) + assert resp.status_code == 404 + + +class TestWebhookEndpoints: + def test_webhook_invalid_signature(self, client): + payload = json.dumps({ + "event_type": "feedback", + "data": [{"text": "test feedback"}], + }) + resp = client.post( + "/api/v1/webhooks/ingest", + content=payload, + headers={ + "Content-Type": "application/json", + "X-Signature": "v1=invalid", + "X-Timestamp": "0", + }, + ) + assert resp.status_code == 401 + + def test_webhook_missing_signature(self, client): + payload = json.dumps({"event_type": "feedback", "data": [{"text": "test"}]}) + resp = client.post( + "/api/v1/webhooks/ingest", + content=payload, + headers={"Content-Type": "application/json"}, + ) + assert resp.status_code == 401 diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py new file mode 100644 index 0000000000000000000000000000000000000000..6e8ee9bd891f08535dad544a8b70d2e32b377240 --- /dev/null +++ b/backend/tests/test_services.py @@ -0,0 +1,276 @@ +"""Tests for core services with mocked ML inference.""" + +from __future__ import annotations + +import json +from unittest.mock import patch + +import numpy as np +import pytest + +from app.models.schemas import FeedbackEntry, SentimentLabel, SentimentResult + + +class TestLanguageDetection: + def test_detect_english(self): + from app.services.language_detection import detect_language + result = detect_language("This is a test sentence in English") + assert result.language in ("en", "unknown") + assert result.confidence >= 0.0 + + def test_detect_empty_text(self): + from app.services.language_detection import detect_language + result = detect_language("") + assert result.language == "unknown" + assert result.confidence == 0.0 + + def test_detect_short_text(self): + from app.services.language_detection import detect_language + result = detect_language("hi") + assert result.language == "unknown" + + def test_batch_detection(self): + from app.services.language_detection import detect_languages_batch + results = detect_languages_batch(["Hello world", "Bonjour le monde", ""]) + assert len(results) == 3 + + +class TestSentiment: + def test_fallback_sentiment_positive(self): + from app.services.sentiment import get_fallback_sentiment + result = get_fallback_sentiment("This is great and amazing!") + assert result.label == SentimentLabel.POSITIVE + + def test_fallback_sentiment_negative(self): + from app.services.sentiment import get_fallback_sentiment + result = get_fallback_sentiment("This is terrible and awful") + assert result.label == SentimentLabel.NEGATIVE + + def test_fallback_sentiment_neutral(self): + from app.services.sentiment import get_fallback_sentiment + result = get_fallback_sentiment("The weather is cloudy today") + assert result.label == SentimentLabel.NEUTRAL + + +class TestFileProcessing: + def test_parse_csv(self): + from app.services.file_processing import parse_csv + content = b"text,source\nHello world,test\nGoodbye world,test\n" + entries = parse_csv(content) + assert len(entries) == 2 + assert entries[0].text == "Hello world" + + def test_parse_json_array(self): + from app.services.file_processing import parse_json + data = [{"text": "entry 1"}, {"text": "entry 2"}] + entries = parse_json(json.dumps(data).encode()) + assert len(entries) == 2 + + def test_parse_json_string_array(self): + from app.services.file_processing import parse_json + data = ["feedback one", "feedback two"] + entries = parse_json(json.dumps(data).encode()) + assert len(entries) == 2 + + def test_parse_json_with_wrapper(self): + from app.services.file_processing import parse_json + data = {"data": [{"text": "entry 1"}]} + entries = parse_json(json.dumps(data).encode()) + assert len(entries) == 1 + + def test_parse_csv_missing_text_column(self): + from app.services.file_processing import parse_csv + content = b"name,age\nJohn,30\n" + # Should fall back to first column or raise + try: + entries = parse_csv(content) + assert len(entries) >= 0 + except ValueError: + pass + + def test_unsupported_format(self): + from app.services.file_processing import parse_file + with pytest.raises(ValueError, match="Unsupported"): + parse_file(b"content", "file.txt") + + +class TestAnomalyDetection: + def test_no_anomalies_stable(self): + from app.services.anomaly_detection import detect_sentiment_anomalies + sentiments = [ + SentimentResult(label=SentimentLabel.NEUTRAL, score=0.5, confidence=0.9) + for _ in range(100) + ] + alerts = detect_sentiment_anomalies(sentiments) + assert len(alerts) == 0 + + def test_detects_sentiment_drop(self): + from app.services.anomaly_detection import detect_sentiment_anomalies + sentiments = [ + SentimentResult(label=SentimentLabel.POSITIVE, score=0.8, confidence=0.9) + for _ in range(60) + ] + sentiments.append( + SentimentResult(label=SentimentLabel.NEGATIVE, score=0.1, confidence=0.9) + ) + alerts = detect_sentiment_anomalies(sentiments, window=50, threshold=1.5) + assert len(alerts) > 0 + assert alerts[0].type.value == "sentiment_drop" + + def test_too_few_entries(self): + from app.services.anomaly_detection import detect_sentiment_anomalies + sentiments = [ + SentimentResult(label=SentimentLabel.NEUTRAL, score=0.5, confidence=0.9) + for _ in range(5) + ] + alerts = detect_sentiment_anomalies(sentiments, window=50) + assert len(alerts) == 0 + + +class TestDataQuality: + def test_empty_entries(self): + from app.services.data_quality import analyze_data_quality + report = analyze_data_quality([]) + assert report.total_entries == 0 + + def test_quality_report(self): + from app.models.schemas import AnalyzedEntry, LanguageResult + from app.services.data_quality import analyze_data_quality + + entries = [ + AnalyzedEntry( + id="1", text="Great product", source="test", + sentiment=SentimentResult(label=SentimentLabel.POSITIVE, score=0.9, confidence=0.95), + language=LanguageResult(language="en", confidence=0.99, method="langdetect"), + topic_id=0, topic_label="Topic 0", + ), + AnalyzedEntry( + id="2", text="Mauvais service", source="test", + sentiment=SentimentResult(label=SentimentLabel.NEGATIVE, score=0.2, confidence=0.4), + language=LanguageResult(language="fr", confidence=0.85, method="langdetect"), + topic_id=1, topic_label="Topic 1", + ), + ] + + report = analyze_data_quality(entries) + assert report.total_entries == 2 + assert report.low_confidence_count == 1 + assert report.mixed_language_count == 1 + + +class TestExport: + def test_export_csv(self): + from app.models.schemas import AnalyzedEntry, LanguageResult + from app.services.export import export_csv + + entries = [ + AnalyzedEntry( + id="1", text="Test", source="test", + sentiment=SentimentResult(label=SentimentLabel.POSITIVE, score=0.9, confidence=0.95), + language=LanguageResult(language="en", confidence=0.99, method="langdetect"), + topic_id=0, topic_label="Topic 0", + ), + ] + result = export_csv(entries) + assert b"id" in result + assert b"Test" in result + + def test_export_json(self): + from app.models.schemas import AnalyzedEntry, LanguageResult + from app.services.export import export_json + + entries = [ + AnalyzedEntry( + id="1", text="Test", source="test", + sentiment=SentimentResult(label=SentimentLabel.POSITIVE, score=0.9, confidence=0.95), + language=LanguageResult(language="en", confidence=0.99, method="langdetect"), + topic_id=0, topic_label="Topic 0", + ), + ] + result = export_json(entries) + data = json.loads(result) + assert len(data) == 1 + assert data[0]["text"] == "Test" + + +def _ml_available() -> bool: + try: + import torch # noqa: F401 + import transformers # noqa: F401 + return True + except ImportError: + return False + + +@pytest.mark.skipif( + not _ml_available(), + reason="ML models not installed — skipping real model tests", +) +class TestRealSentimentModel: + """Diagnostic tests using the real ML model (not mocked).""" + + def test_model_loads(self): + from app.services import sentiment + sentiment._load_model() + assert sentiment._model is not None + + def test_positive_english(self): + from app.services.sentiment import analyze_sentiment_sync + results = analyze_sentiment_sync(["I love this product, it is amazing!"]) + assert len(results) == 1 + assert results[0].label == SentimentLabel.POSITIVE + assert results[0].score > 0.7 + assert results[0].confidence > 0.5 + + def test_negative_english(self): + from app.services.sentiment import analyze_sentiment_sync + results = analyze_sentiment_sync(["This is terrible, worst experience ever."]) + assert len(results) == 1 + assert results[0].label == SentimentLabel.NEGATIVE + assert results[0].score < 0.3 + assert results[0].confidence > 0.5 + + def test_neutral_english(self): + from app.services.sentiment import analyze_sentiment_sync + results = analyze_sentiment_sync(["The order was delivered on Tuesday."]) + assert len(results) == 1 + assert results[0].score > 0.3 + assert results[0].score < 0.7 + + def test_multilingual_german(self): + from app.services.sentiment import analyze_sentiment_sync + results = analyze_sentiment_sync(["Ich bin sehr zufrieden mit dem Service!"]) + assert results[0].label == SentimentLabel.POSITIVE + assert results[0].score > 0.7 + + def test_multilingual_spanish_negative(self): + from app.services.sentiment import analyze_sentiment_sync + results = analyze_sentiment_sync(["Este producto es horrible, no funciona."]) + assert results[0].label == SentimentLabel.NEGATIVE + assert results[0].score < 0.3 + + def test_batch_produces_varied_scores(self): + from app.services.sentiment import analyze_sentiment_sync + texts = [ + "I love this!", + "This is terrible.", + "The weather is normal today.", + "Best purchase I ever made!", + "Worst customer service.", + ] + results = analyze_sentiment_sync(texts) + scores = [r.score for r in results] + assert not all(s == 0.5 for s in scores), f"All scores are 0.5: {scores}" + assert max(scores) - min(scores) > 0.3, f"Score spread too narrow: {scores}" + + def test_scores_not_all_neutral(self): + from app.services.sentiment import analyze_sentiment_sync + texts = [ + "Amazing fantastic wonderful product", + "Horrible terrible awful experience", + "Normal everyday standard thing", + ] + results = analyze_sentiment_sync(texts) + labels = [r.label for r in results] + assert SentimentLabel.NEUTRAL not in labels or len(set(labels)) > 1, \ + f"All labels are neutral: {labels}" diff --git a/demo_data/demo_feedback.csv b/demo_data/demo_feedback.csv new file mode 100644 index 0000000000000000000000000000000000000000..c9290910d14c1606c3f9ab9b566fd4fc68b1b3da --- /dev/null +++ b/demo_data/demo_feedback.csv @@ -0,0 +1,501 @@ +id,text,source,timestamp,rating +0550d633e0fc,Worst customer service I've ever encountered. Score: 1/5.,app_store,2024-01-01T13:02:00,1 +173ef1403ddb,El servicio al cliente fue increíblemente útil.,support_ticket,2024-01-01T22:34:00,4 +e6b5dff85469,Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.,support_ticket,2024-01-01T05:44:00,5 +1a1d71db74d0,カスタマーサービスが非常に親切で助かりました。 スコア: 5/10。,app_store,2024-01-02T19:16:00,5 +e5668ef2ba52,Functional product. Does what it says. Overall rating: 3/5.,survey,2024-01-02T21:14:00,3 +d2ab38a62733,"Standard service, met expectations but didn't exceed them.",twitter,2024-01-02T11:10:00,3 +1e7e92790b1b,Qualité médiocre. Cassé après deux semaines d'utilisation.,email,2024-01-03T19:40:00,1 +1dfe556ae589,"The new feature update is amazing, exactly what I needed.",app_store,2024-01-03T01:14:00,5 +96a99d0e0c89,Excellent value for money. Exceeded my expectations. Overall rating: 5/5.,chat,2024-01-03T08:08:00,5 +7b11f298ec63,Average experience. Delivery was on time.,web_form,2024-01-04T04:32:00,3 +f88afbed6282,Average experience. Delivery was on time.,play_store,2024-01-04T13:38:00,3 +e897b47af36f,Very satisfied with the experience. Highly recommend!,play_store,2024-01-04T00:43:00,5 +a23f0c3ca118,Really impressed with the build quality and design. Score: 5/5.,chat,2024-01-05T09:53:00,4 +dabb18cc10d7,El producto funciona como se describe. Nada especial.,play_store,2024-01-05T15:01:00,3 +c5236a2867ef,Customer service was incredibly helpful and resolved my issue quickly. Overall rating: 5/5.,email,2024-01-06T21:30:00,5 +89d5feeb2dbe,製品は説明通りに動作します。特別なものはありません。,twitter,2024-01-06T12:42:00,3 +bc23021bfd04,Excellent value for money. Exceeded my expectations. Overall rating: 4/5.,app_store,2024-01-06T22:40:00,4 +606cc9cf0904,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,web_form,2024-01-07T08:42:00,3 +8296a3f28b29,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,support_ticket,2024-01-07T15:51:00,1 +3a739e818bfc,The team went above and beyond to help me. Outstanding!,play_store,2024-01-07T23:03:00,4 +670c149d339a,Das Produkt kam beschädigt an und der Support war nutzlos. Note: 2/10.,web_form,2024-01-08T05:17:00,1 +54e348820065,"Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert! Bewertung: 4/5.",web_form,2024-01-08T13:31:00,4 +5327d77f7b35,Product works as described. Nothing special.,web_form,2024-01-08T08:59:00,3 +3393cd2932cc,Qualité médiocre. Cassé après deux semaines d'utilisation.,chat,2024-01-09T15:09:00,1 +b927e7bdc05f,El producto funciona como se describe. Nada especial.,web_form,2024-01-09T01:03:00,3 +888b9177995c,Average experience. Delivery was on time. Overall rating: 3/5.,app_store,2024-01-10T18:15:00,3 +d81bdf9f2357,Mala calidad. Se rompió después de dos semanas.,twitter,2024-01-10T06:42:00,2 +da135c9331b5,"Excellente qualité, livraison rapide. Je recommande vivement !",web_form,2024-01-10T02:00:00,4 +8fd0b472807f,El servicio al cliente fue increíblemente útil.,chat,2024-01-11T02:56:00,5 +92af5b48a7c7,"Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert!",app_store,2024-01-11T20:33:00,5 +b2c2c0cc6bbb,Product works as described. Nothing special.,email,2024-01-11T23:35:00,3 +b634861b9043,Le produit est arrivé endommagé et le support était inutile.,web_form,2024-01-12T08:32:00,1 +1d1538f28fd5,Very satisfied with the experience. Highly recommend! Overall rating: 5/5.,support_ticket,2024-01-12T23:28:00,4 +86c5d6ce1088,Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.,email,2024-01-12T11:37:00,5 +a0a53f1616dd,Absolutely love this product! Best purchase I've made.,chat,2024-01-13T06:43:00,4 +778d0f82ac8f,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,chat,2024-01-13T23:09:00,3 +fb224973241b,It's okay for the price point. Nothing to complain about.,chat,2024-01-13T13:51:00,3 +4ec684fa590c,Product works as described. Nothing special.,web_form,2024-01-14T07:12:00,3 +e800bce99714,Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Note: 3/10.,survey,2024-01-14T02:49:00,3 +343d312dd42b,Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis.,survey,2024-01-15T03:56:00,2 +36f59b9ff17f,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",play_store,2024-01-15T10:27:00,5 +b0d51f1b0512,J'adore ce produit ! Le meilleur achat que j'ai fait.,chat,2024-01-15T17:43:00,5 +aae9126e971c,"Excellente qualité, livraison rapide. Je recommande vivement !",play_store,2024-01-16T21:54:00,5 +e8dc1b963a6f,Worst customer service I've ever encountered.,support_ticket,2024-01-16T06:26:00,2 +46e6d7b29beb,Der Kundenservice war unglaublich hilfreich. Note: 5/10.,chat,2024-01-16T14:43:00,5 +f7c6833d7219,Not worth the price. Very disappointing quality. Overall rating: 2/5.,app_store,2024-01-17T04:01:00,2 +c531f9434103,Expérience terrible. J'ai attendu 3 semaines pour rien.,chat,2024-01-17T20:36:00,2 +163491235d98,Absolutely love this product! Best purchase I've made.,support_ticket,2024-01-17T03:49:00,4 +a259a173d317,Expérience moyenne. Livraison à temps. Note: 3/5.,twitter,2024-01-18T14:42:00,3 +f400e865ee2f,El producto funciona como se describe. Nada especial.,survey,2024-01-18T15:28:00,3 +a6f2276573c5,El producto funciona como se describe. Nada especial. Calificación: 3/10.,play_store,2024-01-19T10:20:00,3 +51757444a3b3,Le service client était incroyablement utile et efficace. Évaluation: 5/10.,support_ticket,2024-01-19T01:13:00,5 +7d35f60f1579,Expérience moyenne. Livraison à temps.,support_ticket,2024-01-19T11:19:00,3 +bf1be5d15e60,Really impressed with the build quality and design. Would rate 5/10.,app_store,2024-01-20T04:39:00,5 +54063714b971,Really impressed with the build quality and design. Overall rating: 5/5.,twitter,2024-01-20T14:20:00,5 +9a502b79557d,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,app_store,2024-01-20T15:01:00,3 +8a0b4fdaa6c3,Qualité médiocre. Cassé après deux semaines d'utilisation. Note: 1/5.,chat,2024-01-21T19:09:00,2 +140d1db71e91,Experiencia terrible. Esperé 3 semanas sin resultado.,twitter,2024-01-21T08:49:00,1 +9975ccf52bc0,It's okay for the price point. Nothing to complain about. Would rate 3/10.,play_store,2024-01-21T22:12:00,3 +bc467cbf5958,Terrible experience. Waited 3 weeks for delivery that never came.,app_store,2024-01-22T18:50:00,2 +a0336571974a,Not worth the price. Very disappointing quality. Would rate 2/10.,web_form,2024-01-22T11:40:00,2 +c3f31a575d3d,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",web_form,2024-01-22T17:49:00,5 +90c35decddb2,Experiencia promedio. La entrega fue puntual.,web_form,2024-01-23T02:17:00,3 +94e74a08a7bf,"Excellente qualité, livraison rapide. Je recommande vivement ! Note: 5/5.",survey,2024-01-23T11:51:00,5 +dfe9e2a10c94,Expérience moyenne. Livraison à temps.,play_store,2024-01-24T16:12:00,3 +b6fb94eb1892,Très satisfait de l'expérience. Hautement recommandé !,app_store,2024-01-24T15:28:00,4 +c8b70629ff7e,"Excellente qualité, livraison rapide. Je recommande vivement !",twitter,2024-01-24T11:30:00,5 +01f86c46c244,Very satisfied with the experience. Highly recommend! Overall rating: 4/5.,email,2024-01-25T23:34:00,4 +f56d52e51b18,Worst customer service I've ever encountered.,chat,2024-01-25T06:18:00,1 +dfd3e9757cbc,Le service client était incroyablement utile et efficace. Évaluation: 4/10.,app_store,2024-01-25T03:55:00,5 +4d976b6f6a88,Absolutely love this product! Best purchase I've made. Would rate 5/10.,app_store,2024-01-26T15:04:00,4 +bf4450bf2dd7,製品は説明通りに動作します。特別なものはありません。,support_ticket,2024-01-26T03:35:00,3 +a1099b2043a5,The team went above and beyond to help me. Outstanding!,play_store,2024-01-26T19:03:00,4 +6539bc5b1089,"The new feature update is amazing, exactly what I needed. Would rate 4/10.",web_form,2024-01-27T14:44:00,5 +733c121b417e,Très satisfait de l'expérience. Hautement recommandé ! Évaluation: 4/10.,chat,2024-01-27T03:34:00,4 +ac1cca4b7bc4,Average experience. Delivery was on time.,survey,2024-01-28T19:47:00,3 +2060c03dd652,The team went above and beyond to help me. Outstanding!,web_form,2024-01-28T16:34:00,5 +24c8a0d9693f,"Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert!",play_store,2024-01-28T08:01:00,4 +f4867eaece55,Expérience moyenne. Livraison à temps.,web_form,2024-01-29T05:30:00,3 +3c19c87c4a10,"Standard service, met expectations but didn't exceed them.",support_ticket,2024-01-29T15:22:00,3 +ed52a1a6d205,Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis.,support_ticket,2024-01-29T15:18:00,2 +4f7928992601,Customer service was incredibly helpful and resolved my issue quickly.,app_store,2024-01-30T12:55:00,5 +f198df0da3a0,Muy satisfecho con la experiencia. ¡Lo recomiendo!,survey,2024-01-30T01:13:00,4 +dc1b9aa72d14,J'adore ce produit ! Le meilleur achat que j'ai fait. Note: 4/5.,play_store,2024-01-30T17:26:00,4 +40946150cc51,"Great quality, fast shipping. Will definitely order again.",survey,2024-01-31T22:18:00,5 +2beccf55e2df,Functional product. Does what it says. Would rate 3/10.,survey,2024-01-31T13:21:00,3 +dd7733e046a7,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,email,2024-01-31T15:55:00,2 +0843fb436b8d,Muy satisfecho con la experiencia. ¡Lo recomiendo!,chat,2024-02-01T10:55:00,4 +75baf2ad5cf2,J'adore ce produit ! Le meilleur achat que j'ai fait.,support_ticket,2024-02-01T15:45:00,5 +b78ef5099494,Excellent value for money. Exceeded my expectations.,web_form,2024-02-02T14:06:00,4 +6d6a2a6b90f8,Le service client était incroyablement utile et efficace. Note: 5/5.,support_ticket,2024-02-02T10:54:00,5 +5ad7a5d5d629,Product works as described. Nothing special. Would rate 3/10.,play_store,2024-02-02T03:48:00,3 +628981826b8b,製品が破損して届きました。サポートも役に立ちませんでした。,twitter,2024-02-03T01:18:00,2 +82d9e7af73b4,El servicio al cliente fue increíblemente útil. Calificación: 4/10.,chat,2024-02-03T18:09:00,5 +4d93ae3dd21c,Très satisfait de l'expérience. Hautement recommandé !,email,2024-02-03T21:34:00,4 +b676359c603a,製品は説明通りに動作します。特別なものはありません。,survey,2024-02-04T13:44:00,3 +b70f2085da3e,Really impressed with the build quality and design. Score: 5/5.,support_ticket,2024-02-04T09:01:00,5 +5d084a7ecc0b,J'adore ce produit ! Le meilleur achat que j'ai fait.,survey,2024-02-04T23:31:00,4 +32678c956496,Very satisfied with the experience. Highly recommend!,email,2024-02-05T23:49:00,4 +50250461bc18,Excellent value for money. Exceeded my expectations.,survey,2024-02-05T02:58:00,5 +2cfe16ffcac1,Excellent value for money. Exceeded my expectations.,email,2024-02-06T08:53:00,5 +64b362a374c5,It's okay for the price point. Nothing to complain about.,email,2024-02-06T14:04:00,3 +f8cf2040bdc3,Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.,survey,2024-02-06T14:23:00,3 +c5a4102c0ec3,Absolutely love this product! Best purchase I've made.,chat,2024-02-07T17:20:00,4 +e02fb7dcd6c1,"Standard service, met expectations but didn't exceed them. Would rate 3/10.",chat,2024-02-07T03:06:00,3 +d847a34ad2a9,Très satisfait de l'expérience. Hautement recommandé !,support_ticket,2024-02-07T23:09:00,4 +9c53a13f87f4,ひどい経験でした。3週間待っても届きませんでした。 スコア: 1/10。,twitter,2024-02-08T07:54:00,2 +2fdecc6a7d5e,カスタマーサービスが非常に親切で助かりました。 スコア: 5/10。,chat,2024-02-08T02:42:00,4 +c5ccf0df4480,Average experience. Delivery was on time.,chat,2024-02-08T06:04:00,3 +6c260231024c,Absolutely love this product! Best purchase I've made. Overall rating: 4/5.,app_store,2024-02-09T03:42:00,4 +c542220e734d,"Excellente qualité, livraison rapide. Je recommande vivement ! Évaluation: 5/10.",play_store,2024-02-09T16:07:00,5 +18138fbc57b6,El producto funciona como se describe. Nada especial.,app_store,2024-02-09T07:39:00,3 +075c6e01e4f8,Misleading product description. Nothing like advertised.,play_store,2024-02-10T13:43:00,2 +b9c98afc431e,"Great quality, fast shipping. Will definitely order again. Score: 5/5.",web_form,2024-02-10T16:18:00,5 +20b0b9cb0def,Functional product. Does what it says.,web_form,2024-02-11T06:27:00,3 +ebfecc937650,The team went above and beyond to help me. Outstanding!,twitter,2024-02-11T10:27:00,5 +ac1a64402be9,Expérience moyenne. Livraison à temps. Note: 3/5.,twitter,2024-02-11T23:47:00,3 +9cbd8bbf44e9,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",support_ticket,2024-02-12T13:22:00,4 +9c56d6667025,Poor quality materials. Broke after two weeks of use. Would rate 1/10.,play_store,2024-02-12T17:23:00,1 +5c1d11a5dd01,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,survey,2024-02-12T21:53:00,3 +18ff62b0b951,"Excellente qualité, livraison rapide. Je recommande vivement !",email,2024-02-13T05:55:00,5 +8c7784e2e007,¡Me encanta este producto! La mejor compra que he hecho. Calificación: 4/10.,twitter,2024-02-13T10:10:00,5 +f7115d9153c8,El producto funciona como se describe. Nada especial.,app_store,2024-02-13T04:10:00,3 +d68d8f0b4219,Really impressed with the build quality and design.,play_store,2024-02-14T06:48:00,5 +a49854c53956,Très satisfait de l'expérience. Hautement recommandé !,chat,2024-02-14T09:02:00,5 +c4d95d92ddee,"The new feature update is amazing, exactly what I needed.",twitter,2024-02-15T08:18:00,4 +389f5a9171f1,"Great quality, fast shipping. Will definitely order again.",twitter,2024-02-15T22:14:00,4 +021d3dbea95a,Product works as described. Nothing special.,twitter,2024-02-15T02:55:00,3 +ce92f5aa1131,Terrible experience. Waited 3 weeks for delivery that never came.,web_form,2024-02-16T10:10:00,1 +247d1dd5de5a,The team went above and beyond to help me. Outstanding!,support_ticket,2024-02-16T16:05:00,5 +04dc02158007,J'adore ce produit ! Le meilleur achat que j'ai fait.,chat,2024-02-16T03:33:00,5 +b4413ca52dda,Das Produkt kam beschädigt an und der Support war nutzlos. Bewertung: 2/5.,play_store,2024-02-17T19:09:00,2 +a05c3f9d5e29,Muy satisfecho con la experiencia. ¡Lo recomiendo!,twitter,2024-02-17T18:41:00,5 +b9a9d3e33dc0,Very satisfied with the experience. Highly recommend!,twitter,2024-02-17T18:03:00,4 +fed9888473f7,Excellent value for money. Exceeded my expectations.,play_store,2024-02-18T02:41:00,5 +2ca433075686,素晴らしい品質です。強くお勧めします!,chat,2024-02-18T14:58:00,5 +ac04fd57afc3,カスタマーサービスが非常に親切で助かりました。 評価: 5/5。,web_form,2024-02-18T07:45:00,5 +da14a011772e,カスタマーサービスが非常に親切で助かりました。,app_store,2024-02-19T10:43:00,5 +1cdd5b8c0c40,Muy satisfecho con la experiencia. ¡Lo recomiendo! Puntuación: 4/5.,support_ticket,2024-02-19T11:19:00,4 +8da171ee35f4,Excellent value for money. Exceeded my expectations.,play_store,2024-02-20T21:44:00,5 +5d5d109b06c0,"The new feature update is amazing, exactly what I needed.",chat,2024-02-20T15:12:00,4 +42101be2f892,Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.,app_store,2024-02-20T17:53:00,5 +aee5fd8241b0,"Great quality, fast shipping. Will definitely order again.",email,2024-02-21T19:51:00,5 +9720c191019c,Le service client était incroyablement utile et efficace.,web_form,2024-02-21T19:18:00,4 +bf2323afae55,Der Kundenservice war unglaublich hilfreich.,web_form,2024-02-21T06:23:00,5 +dd7462f688fe,Experiencia promedio. La entrega fue puntual.,email,2024-02-22T06:51:00,3 +a14a777be24b,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,play_store,2024-02-22T22:54:00,1 +d5c68a8124fe,Functional product. Does what it says. Would rate 3/10.,app_store,2024-02-22T11:59:00,3 +063783a4c96d,The team went above and beyond to help me. Outstanding!,app_store,2024-02-23T11:14:00,5 +eb64c0add977,El producto funciona como se describe. Nada especial. Calificación: 3/10.,app_store,2024-02-23T14:39:00,3 +23566e75a4a4,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,support_ticket,2024-02-24T19:33:00,2 +5f69f5c7df59,Poor quality materials. Broke after two weeks of use.,play_store,2024-02-24T19:29:00,2 +302e4e5e6d4e,Poor quality materials. Broke after two weeks of use.,app_store,2024-02-24T09:27:00,1 +169f965e3cb6,Terrible experience. Waited 3 weeks for delivery that never came.,app_store,2024-02-25T11:04:00,2 +73c9f76a8afa,Excellent value for money. Exceeded my expectations. Would rate 4/10.,play_store,2024-02-25T07:46:00,5 +7e3b9ab6df95,El servicio al cliente fue increíblemente útil.,web_form,2024-02-25T21:29:00,4 +ac10cdfed3bd,Très satisfait de l'expérience. Hautement recommandé ! Note: 4/5.,survey,2024-02-26T10:59:00,5 +aa424db54c37,Le produit est arrivé endommagé et le support était inutile. Note: 2/5.,support_ticket,2024-02-26T11:27:00,2 +66bf64e93e6c,Misleading product description. Nothing like advertised.,play_store,2024-02-26T16:17:00,1 +77ec32f577db,製品が破損して届きました。サポートも役に立ちませんでした。,play_store,2024-02-27T10:06:00,2 +b79aac27202a,El peor servicio al cliente que he experimentado. Puntuación: 2/5.,twitter,2024-02-27T23:55:00,2 +6caaf83f597c,Le produit est arrivé endommagé et le support était inutile. Note: 2/5.,web_form,2024-02-27T04:54:00,1 +526d8f64594d,"Excellente qualité, livraison rapide. Je recommande vivement !",email,2024-02-28T18:02:00,5 +d508b22b559b,Qualité médiocre. Cassé après deux semaines d'utilisation. Évaluation: 2/10.,app_store,2024-02-28T07:07:00,2 +7679a8a35fc1,製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 2/10。,web_form,2024-02-29T22:58:00,1 +bc36cbb607e6,"Great quality, fast shipping. Will definitely order again.",twitter,2024-02-29T14:05:00,5 +ae2f775d7121,Worst customer service I've ever encountered.,email,2024-02-29T22:45:00,1 +2c27fcd963d8,"Great quality, fast shipping. Will definitely order again. Score: 5/5.",web_form,2024-03-01T18:23:00,4 +ab62a3f12541,Really impressed with the build quality and design.,play_store,2024-03-01T13:52:00,4 +e5a8841b80fc,Not worth the price. Very disappointing quality. Score: 1/5.,play_store,2024-03-01T01:53:00,2 +126ea062cbd8,"The new feature update is amazing, exactly what I needed.",survey,2024-03-02T16:57:00,4 +d0d07e36a0b8,カスタマーサービスが非常に親切で助かりました。 評価: 5/5。,twitter,2024-03-02T05:42:00,4 +4aeee428251e,素晴らしい品質です。強くお勧めします! スコア: 4/10。,survey,2024-03-02T20:34:00,5 +4e085be965de,Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis. Note: 1/10.,web_form,2024-03-03T13:30:00,2 +dc35614e7f48,Not worth the price. Very disappointing quality.,email,2024-03-03T11:25:00,1 +6391048b938d,Worst customer service I've ever encountered.,app_store,2024-03-03T04:06:00,2 +614860c7bc7f,Average experience. Delivery was on time.,twitter,2024-03-04T18:46:00,3 +0ba94e4aae2c,El peor servicio al cliente que he experimentado.,play_store,2024-03-04T15:01:00,2 +2cd21a3b6407,It's okay for the price point. Nothing to complain about. Would rate 3/10.,play_store,2024-03-05T17:00:00,3 +a7771b767fca,Expérience moyenne. Livraison à temps.,chat,2024-03-05T12:03:00,3 +6626e8341b30,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",play_store,2024-03-05T19:43:00,4 +d67316930082,Expérience terrible. J'ai attendu 3 semaines pour rien.,email,2024-03-06T11:48:00,2 +7d73508b37a0,The software crashes constantly. Very frustrating.,web_form,2024-03-06T15:33:00,1 +a6ca4b5e29b7,Experiencia terrible. Esperé 3 semanas sin resultado.,survey,2024-03-06T19:47:00,2 +4a8db1e38a00,Product arrived damaged and customer support was unhelpful.,support_ticket,2024-03-07T21:07:00,1 +a9c8ba0f3417,Poor quality materials. Broke after two weeks of use.,email,2024-03-07T18:30:00,1 +f2affdb0a287,Functional product. Does what it says.,app_store,2024-03-07T03:12:00,3 +5f9d2ff7ec45,Poor quality materials. Broke after two weeks of use.,web_form,2024-03-08T19:58:00,1 +a63a8fffc3ed,El peor servicio al cliente que he experimentado.,web_form,2024-03-08T10:11:00,2 +5399e5852a49,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,web_form,2024-03-09T22:16:00,1 +bdfb9dacf3d3,Worst customer service I've ever encountered.,web_form,2024-03-09T22:13:00,2 +361d4fb8d236,ひどい経験でした。3週間待っても届きませんでした。 評価: 2/5。,survey,2024-03-09T09:02:00,2 +9acdae4d45dd,Expérience moyenne. Livraison à temps. Note: 3/5.,chat,2024-03-10T19:47:00,3 +2d110d201206,Experiencia terrible. Esperé 3 semanas sin resultado. Puntuación: 2/5.,email,2024-03-10T21:49:00,1 +50076c00863c,ひどい経験でした。3週間待っても届きませんでした。,survey,2024-03-10T06:48:00,2 +2b666b3d4da8,製品が破損して届きました。サポートも役に立ちませんでした。,app_store,2024-03-11T07:34:00,2 +2ece8489ed9b,Product works as described. Nothing special.,web_form,2024-03-11T12:50:00,3 +4b4d830d2a88,Functional product. Does what it says.,web_form,2024-03-11T10:18:00,3 +59b23f162bdd,Average experience. Delivery was on time.,web_form,2024-03-12T10:10:00,3 +ec5cbb02072a,Very satisfied with the experience. Highly recommend!,support_ticket,2024-03-12T06:11:00,5 +01805b045f78,Le service client était incroyablement utile et efficace.,chat,2024-03-13T15:20:00,4 +49386ec69c94,Worst customer service I've ever encountered.,app_store,2024-03-13T09:37:00,2 +656f19b49e10,Terrible experience. Waited 3 weeks for delivery that never came.,survey,2024-03-13T05:25:00,2 +f32ecdbf3bf2,The software crashes constantly. Very frustrating.,twitter,2024-03-14T14:47:00,2 +9b0946c02e90,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,play_store,2024-03-14T21:48:00,3 +22705c465bbf,¡Me encanta este producto! La mejor compra que he hecho.,email,2024-03-14T16:12:00,4 +d936f17c9fe1,Average experience. Delivery was on time.,web_form,2024-03-15T15:05:00,3 +11cd45dc8595,Absolutely love this product! Best purchase I've made.,survey,2024-03-15T21:51:00,4 +a426493fdb35,Poor quality materials. Broke after two weeks of use.,twitter,2024-03-15T01:27:00,1 +54f59417dfaf,"Standard service, met expectations but didn't exceed them. Score: 3/5.",support_ticket,2024-03-16T23:41:00,3 +8807cfab33fa,ひどい経験でした。3週間待っても届きませんでした。,email,2024-03-16T21:42:00,1 +9599268f98b3,El servicio al cliente fue increíblemente útil.,app_store,2024-03-16T07:53:00,4 +b32102273fab,Expérience terrible. J'ai attendu 3 semaines pour rien.,web_form,2024-03-17T07:15:00,1 +db01f92bc44d,製品が破損して届きました。サポートも役に立ちませんでした。 評価: 1/5。,app_store,2024-03-17T02:06:00,1 +5b83380eb993,Misleading product description. Nothing like advertised. Score: 1/5.,twitter,2024-03-18T09:14:00,1 +5f23203cfaf4,Poor quality materials. Broke after two weeks of use.,email,2024-03-18T08:03:00,2 +12101320cd4f,The team went above and beyond to help me. Outstanding!,support_ticket,2024-03-18T10:44:00,5 +81e1839285f2,Misleading product description. Nothing like advertised. Would rate 2/10.,support_ticket,2024-03-19T01:35:00,1 +413b7c7fe009,Not worth the price. Very disappointing quality.,twitter,2024-03-19T14:54:00,1 +1733d7255f2c,Absolutely love this product! Best purchase I've made.,chat,2024-03-19T06:51:00,5 +1cc03469e91f,Product works as described. Nothing special. Overall rating: 3/5.,chat,2024-03-20T22:38:00,3 +545d0bb313e0,Expérience terrible. J'ai attendu 3 semaines pour rien.,survey,2024-03-20T20:22:00,2 +9217d6bb48e9,Terrible experience. Waited 3 weeks for delivery that never came.,twitter,2024-03-20T23:30:00,2 +d5cec0823ea3,Poor quality materials. Broke after two weeks of use.,support_ticket,2024-03-21T00:16:00,1 +5c8873f259db,"The new feature update is amazing, exactly what I needed.",twitter,2024-03-21T20:43:00,5 +73d70d5125b3,Experiencia promedio. La entrega fue puntual.,twitter,2024-03-22T23:50:00,3 +c2e558b70544,Product arrived damaged and customer support was unhelpful.,app_store,2024-03-22T02:52:00,2 +bd26ada51971,¡Me encanta este producto! La mejor compra que he hecho.,web_form,2024-03-22T00:13:00,5 +e564a7c66c93,Qualité médiocre. Cassé après deux semaines d'utilisation.,app_store,2024-03-23T21:31:00,2 +72db904b73ab,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",support_ticket,2024-03-23T09:01:00,4 +f3ec3339069f,El producto funciona como se describe. Nada especial. Calificación: 3/10.,support_ticket,2024-03-23T02:23:00,3 +26eff9c842d7,Product works as described. Nothing special.,app_store,2024-03-24T20:48:00,3 +dc1d6ba29517,El producto llegó dañado y el soporte no ayudó.,play_store,2024-03-24T22:10:00,2 +e7aa1ef201a2,Terrible experience. Waited 3 weeks for delivery that never came.,chat,2024-03-24T01:14:00,2 +d0b623ad00a5,Very satisfied with the experience. Highly recommend!,app_store,2024-03-25T18:42:00,4 +3ae049d27d41,"Great quality, fast shipping. Will definitely order again.",support_ticket,2024-03-25T22:52:00,4 +89d0c21beda2,The team went above and beyond to help me. Outstanding!,support_ticket,2024-03-25T17:21:00,4 +a375efd736b8,製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 1/10。,survey,2024-03-26T10:01:00,2 +d18ac8e50ba5,El peor servicio al cliente que he experimentado.,support_ticket,2024-03-26T21:37:00,2 +e5cedce66157,Le produit fonctionne comme décrit. Rien de spécial. Évaluation: 3/10.,play_store,2024-03-27T11:06:00,3 +e7f1427704dc,Le produit est arrivé endommagé et le support était inutile. Évaluation: 2/10.,play_store,2024-03-27T03:01:00,1 +b74a85c0071c,Expérience terrible. J'ai attendu 3 semaines pour rien. Note: 2/5.,email,2024-03-27T18:33:00,1 +b867a1a840c4,Really impressed with the build quality and design. Would rate 4/10.,play_store,2024-03-28T09:30:00,5 +404c92898c7e,Expérience moyenne. Livraison à temps.,play_store,2024-03-28T06:29:00,3 +be88c46c038e,The team went above and beyond to help me. Outstanding!,twitter,2024-03-28T10:28:00,5 +18b023c1103a,Experiencia terrible. Esperé 3 semanas sin resultado.,twitter,2024-03-29T05:25:00,2 +b6a80b36aae3,Expérience moyenne. Livraison à temps.,play_store,2024-03-29T12:23:00,3 +86a1de9c3563,El peor servicio al cliente que he experimentado.,survey,2024-03-29T06:28:00,2 +887449793d2d,Worst customer service I've ever encountered. Overall rating: 2/5.,web_form,2024-03-30T14:38:00,2 +b693dcc2f5c9,Expérience terrible. J'ai attendu 3 semaines pour rien.,play_store,2024-03-30T17:38:00,1 +fdb477af36db,Product arrived damaged and customer support was unhelpful.,support_ticket,2024-03-31T20:43:00,2 +fa057f188161,"The new feature update is amazing, exactly what I needed.",chat,2024-03-31T16:35:00,5 +f42ca40c1391,Qualité médiocre. Cassé après deux semaines d'utilisation.,app_store,2024-03-31T04:22:00,1 +a34f61b66c0b,製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 2/10。,chat,2024-04-01T14:50:00,2 +aa21d354df9f,Misleading product description. Nothing like advertised.,play_store,2024-04-01T09:02:00,2 +4c24c3d87472,Le produit est arrivé endommagé et le support était inutile.,email,2024-04-01T23:15:00,2 +4e19280e0617,製品が破損して届きました。サポートも役に立ちませんでした。,email,2024-04-02T20:11:00,2 +bc4c02db08a7,El servicio al cliente fue increíblemente útil.,app_store,2024-04-02T13:24:00,4 +f3846d7596d4,Le produit est arrivé endommagé et le support était inutile.,twitter,2024-04-02T17:11:00,1 +fb30ab40f582,Misleading product description. Nothing like advertised.,support_ticket,2024-04-03T20:20:00,1 +3cb99c3724bd,Schlechte Qualität. Nach zwei Wochen kaputt gegangen. Note: 1/10.,survey,2024-04-03T03:15:00,1 +5b4e15892a35,The app is full of bugs. Each update makes it worse.,support_ticket,2024-04-03T04:36:00,1 +cf9df54106d4,Experiencia terrible. Esperé 3 semanas sin resultado.,email,2024-04-04T15:18:00,2 +4fe1947761d1,Experiencia promedio. La entrega fue puntual.,chat,2024-04-04T15:08:00,3 +9d053f2d608c,Misleading product description. Nothing like advertised.,app_store,2024-04-05T02:36:00,2 +81f76698d94e,Very satisfied with the experience. Highly recommend! Score: 5/5.,twitter,2024-04-05T03:17:00,4 +322d70094e7b,製品が破損して届きました。サポートも役に立ちませんでした。,chat,2024-04-05T20:48:00,1 +7f607caac54a,Terrible experience. Waited 3 weeks for delivery that never came.,chat,2024-04-06T03:49:00,2 +dc80149104c9,Experiencia promedio. La entrega fue puntual. Puntuación: 3/5.,survey,2024-04-06T18:22:00,3 +dff2acdd415b,Functional product. Does what it says. Overall rating: 3/5.,email,2024-04-06T08:55:00,3 +ba2038d093d8,"The new feature update is amazing, exactly what I needed.",web_form,2024-04-07T21:25:00,4 +ab8d1e151121,Terrible experience. Waited 3 weeks for delivery that never came.,survey,2024-04-07T02:25:00,2 +f584e5b57190,Product works as described. Nothing special.,web_form,2024-04-07T23:05:00,3 +59b356c778b5,"Standard service, met expectations but didn't exceed them.",twitter,2024-04-08T15:17:00,3 +6a4150e88d00,Misleading product description. Nothing like advertised.,app_store,2024-04-08T21:48:00,1 +c8ea06ef9bce,Das Produkt kam beschädigt an und der Support war nutzlos. Bewertung: 2/5.,email,2024-04-09T13:12:00,1 +91d27827fe8f,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,twitter,2024-04-09T08:05:00,3 +2e25ae4c6ca2,Poor quality materials. Broke after two weeks of use.,play_store,2024-04-09T02:45:00,1 +02f843e52619,Poor quality materials. Broke after two weeks of use.,app_store,2024-04-10T14:00:00,1 +a57a13fde7c1,Average experience. Delivery was on time.,play_store,2024-04-10T07:24:00,3 +5ab13cadd7a8,Das Produkt kam beschädigt an und der Support war nutzlos.,email,2024-04-10T04:41:00,1 +1ad7a81621b8,Not worth the price. Very disappointing quality.,email,2024-04-11T20:47:00,2 +f3a9c5b14225,Product works as described. Nothing special. Would rate 3/10.,email,2024-04-11T10:50:00,3 +fcc9d208c2ca,Worst customer service I've ever encountered.,survey,2024-04-11T22:01:00,1 +580e6abb0f9b,Product arrived damaged and customer support was unhelpful.,support_ticket,2024-04-12T07:31:00,1 +2e7f346e325c,El producto llegó dañado y el soporte no ayudó.,support_ticket,2024-04-12T10:08:00,2 +271077900307,Misleading product description. Nothing like advertised.,play_store,2024-04-12T02:01:00,2 +ff393310ba05,ひどい経験でした。3週間待っても届きませんでした。,support_ticket,2024-04-13T20:45:00,2 +73d3b30c0681,El servicio al cliente fue increíblemente útil. Puntuación: 5/5.,support_ticket,2024-04-13T17:20:00,4 +cb138b5884f9,Le produit est arrivé endommagé et le support était inutile. Évaluation: 2/10.,app_store,2024-04-14T19:38:00,1 +28c3aea3ee75,J'adore ce produit ! Le meilleur achat que j'ai fait. Évaluation: 5/10.,survey,2024-04-14T04:47:00,4 +75df13da99b3,Terrible experience. Waited 3 weeks for delivery that never came. Score: 2/5.,web_form,2024-04-14T11:03:00,2 +10f96758c1a8,Qualité médiocre. Cassé après deux semaines d'utilisation.,app_store,2024-04-15T03:14:00,1 +03c96e160bc7,The app is full of bugs. Each update makes it worse.,app_store,2024-04-15T05:59:00,1 +9a1f59c27bab,Not worth the price. Very disappointing quality. Score: 2/5.,app_store,2024-04-15T21:57:00,1 +a9015f11104c,製品は説明通りに動作します。特別なものはありません。 スコア: 3/10。,web_form,2024-04-16T02:35:00,3 +12741663077a,Mala calidad. Se rompió después de dos semanas.,twitter,2024-04-16T07:29:00,1 +14100a5c8d69,Experiencia terrible. Esperé 3 semanas sin resultado. Calificación: 1/10.,app_store,2024-04-16T20:53:00,1 +69252e0bbf8d,It's okay for the price point. Nothing to complain about. Overall rating: 3/5.,survey,2024-04-17T04:45:00,3 +501c386ac0cc,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,play_store,2024-04-17T11:29:00,3 +76dc7fa1c3b5,Product arrived damaged and customer support was unhelpful.,survey,2024-04-18T04:15:00,2 +2cd63c85fdfb,It's okay for the price point. Nothing to complain about.,play_store,2024-04-18T08:14:00,3 +e09a987e999c,El producto funciona como se describe. Nada especial. Calificación: 3/10.,twitter,2024-04-18T03:34:00,3 +d2db59bbc719,Terrible experience. Waited 3 weeks for delivery that never came. Would rate 1/10.,chat,2024-04-19T04:05:00,2 +b7c8cfe17f76,Expérience terrible. J'ai attendu 3 semaines pour rien.,support_ticket,2024-04-19T05:01:00,1 +0d98b2509fdb,"Great quality, fast shipping. Will definitely order again. Overall rating: 4/5.",play_store,2024-04-19T12:23:00,5 +afb29e245af2,Really impressed with the build quality and design.,twitter,2024-04-20T02:57:00,4 +a92617a33970,Functional product. Does what it says.,web_form,2024-04-20T23:25:00,3 +f01979c7e3dd,Poor quality materials. Broke after two weeks of use.,web_form,2024-04-20T08:17:00,1 +9ee8188eec34,Mala calidad. Se rompió después de dos semanas.,app_store,2024-04-21T17:30:00,2 +4d25d21b5d54,Poor quality materials. Broke after two weeks of use.,app_store,2024-04-21T17:50:00,2 +68cec6a195e0,Expérience terrible. J'ai attendu 3 semaines pour rien.,support_ticket,2024-04-21T16:20:00,1 +62a6a3ef328b,¡Me encanta este producto! La mejor compra que he hecho.,web_form,2024-04-22T08:03:00,5 +9db15c8adf74,Average experience. Delivery was on time. Would rate 3/10.,chat,2024-04-22T05:11:00,3 +ddd461ac0e83,It's okay for the price point. Nothing to complain about.,chat,2024-04-23T18:09:00,3 +a8995de033da,Product arrived damaged and customer support was unhelpful. Overall rating: 1/5.,chat,2024-04-23T10:58:00,2 +cf0609c1cfcb,ひどい経験でした。3週間待っても届きませんでした。 スコア: 1/10。,support_ticket,2024-04-23T02:23:00,2 +0b46244aad20,The software crashes constantly. Very frustrating.,web_form,2024-04-24T13:50:00,1 +973f981ca8ee,Mala calidad. Se rompió después de dos semanas. Calificación: 2/10.,app_store,2024-04-24T04:11:00,1 +a7fcf4aa8eb4,Poor quality materials. Broke after two weeks of use.,web_form,2024-04-24T03:59:00,2 +bc6817c71804,Not worth the price. Very disappointing quality. Overall rating: 2/5.,chat,2024-04-25T08:13:00,2 +eecc004d8f5d,Product arrived damaged and customer support was unhelpful. Would rate 2/10.,support_ticket,2024-04-25T15:56:00,2 +822bd418d317,"The new feature update is amazing, exactly what I needed. Overall rating: 4/5.",play_store,2024-04-25T10:16:00,4 +5a608e492af1,Terrible experience. Waited 3 weeks for delivery that never came.,web_form,2024-04-26T02:21:00,2 +2e2078c0d315,"Standard service, met expectations but didn't exceed them. Overall rating: 3/5.",survey,2024-04-26T13:44:00,3 +9992b76025c3,Misleading product description. Nothing like advertised. Overall rating: 1/5.,web_form,2024-04-27T09:52:00,1 +9b9167666561,Very satisfied with the experience. Highly recommend!,play_store,2024-04-27T15:54:00,5 +cacc430f759e,Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis. Note: 1/10.,web_form,2024-04-27T11:11:00,2 +4d2f814dbaa1,Worst customer service I've ever encountered.,play_store,2024-04-28T04:48:00,2 +f494ca790f4b,Functional product. Does what it says. Score: 3/5.,web_form,2024-04-28T21:55:00,3 +15b6dbaade39,Product arrived damaged and customer support was unhelpful.,email,2024-04-28T16:51:00,2 +d2d7176a11e3,製品が破損して届きました。サポートも役に立ちませんでした。,support_ticket,2024-04-29T04:02:00,2 +a50f1450851b,Das Produkt kam beschädigt an und der Support war nutzlos.,play_store,2024-04-29T08:20:00,2 +e1e0d4d62c30,Mala calidad. Se rompió después de dos semanas.,support_ticket,2024-04-29T00:50:00,2 +40efc5eb438f,¡Me encanta este producto! La mejor compra que he hecho.,play_store,2024-04-30T18:49:00,5 +8242512ea8e0,"Standard service, met expectations but didn't exceed them.",email,2024-04-30T15:02:00,3 +50d18a94c572,Muy satisfecho con la experiencia. ¡Lo recomiendo! Calificación: 5/10.,twitter,2024-04-30T08:57:00,4 +7d217031f852,It's okay for the price point. Nothing to complain about.,play_store,2024-05-01T22:43:00,3 +48b0b5be77e4,Expérience terrible. J'ai attendu 3 semaines pour rien. Évaluation: 1/10.,support_ticket,2024-05-01T06:58:00,2 +ea09df97e12f,El producto funciona como se describe. Nada especial. Puntuación: 3/5.,chat,2024-05-02T06:56:00,3 +1d220675c4a0,Excellent value for money. Exceeded my expectations.,web_form,2024-05-02T08:23:00,5 +c61f1802c573,Misleading product description. Nothing like advertised.,web_form,2024-05-02T05:55:00,2 +5cddb2374982,Schlechte Qualität. Nach zwei Wochen kaputt gegangen.,app_store,2024-05-03T02:18:00,1 +33af4f2edc2b,Experiencia terrible. Esperé 3 semanas sin resultado.,chat,2024-05-03T20:14:00,2 +c9cd5f4f05f3,Schlechte Qualität. Nach zwei Wochen kaputt gegangen. Bewertung: 1/5.,survey,2024-05-03T08:38:00,2 +c52d31c416e8,Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.,twitter,2024-05-04T00:32:00,3 +2730ef05e7fa,Not worth the price. Very disappointing quality.,survey,2024-05-04T20:20:00,2 +10165b116545,Experiencia terrible. Esperé 3 semanas sin resultado.,twitter,2024-05-04T19:25:00,1 +5fb507265130,ひどい経験でした。3週間待っても届きませんでした。,app_store,2024-05-05T12:24:00,2 +623e6a443af5,Misleading product description. Nothing like advertised.,survey,2024-05-05T21:22:00,1 +c87974c8b9a9,Very satisfied with the experience. Highly recommend!,email,2024-05-05T17:48:00,5 +9d1cbde7fbf4,Very satisfied with the experience. Highly recommend!,survey,2024-05-06T10:23:00,5 +7cf995866844,Functional product. Does what it says.,web_form,2024-05-06T19:04:00,3 +250d6266ed15,It's okay for the price point. Nothing to complain about.,chat,2024-05-07T04:08:00,3 +843d41f74d2d,Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Note: 3/10.,twitter,2024-05-07T20:11:00,3 +0f21bc3ec57b,"Great quality, fast shipping. Will definitely order again.",play_store,2024-05-07T05:31:00,4 +7d02a4ee50ef,Misleading product description. Nothing like advertised.,chat,2024-05-08T01:34:00,1 +170b42fb7a19,Le produit fonctionne comme décrit. Rien de spécial. Évaluation: 3/10.,play_store,2024-05-08T09:08:00,3 +ff7859d5527c,Der Kundenservice war unglaublich hilfreich.,twitter,2024-05-08T14:46:00,5 +061fdaedff5f,カスタマーサービスが非常に親切で助かりました。,support_ticket,2024-05-09T05:58:00,4 +1891677b908a,この製品が大好きです!最高の買い物でした。,email,2024-05-09T18:32:00,4 +0647b75ffb0c,Expérience terrible. J'ai attendu 3 semaines pour rien.,app_store,2024-05-09T08:49:00,1 +150aa21654f3,Terrible experience. Waited 3 weeks for delivery that never came.,survey,2024-05-10T12:07:00,1 +aa3faf994758,J'adore ce produit ! Le meilleur achat que j'ai fait.,survey,2024-05-10T21:06:00,4 +1aa8fb020790,Functional product. Does what it says. Overall rating: 3/5.,survey,2024-05-11T19:12:00,3 +150dcc6d227d,Product arrived damaged and customer support was unhelpful.,web_form,2024-05-11T06:44:00,1 +8d7976b8ccfa,J'adore ce produit ! Le meilleur achat que j'ai fait.,email,2024-05-11T21:31:00,4 +6f858c35935c,Worst customer service I've ever encountered.,survey,2024-05-12T14:18:00,1 +848dcd163944,Absolutely love this product! Best purchase I've made.,twitter,2024-05-12T11:51:00,5 +bd84f35ea401,J'adore ce produit ! Le meilleur achat que j'ai fait. Évaluation: 4/10.,survey,2024-05-12T14:39:00,4 +1f2d3e7e9212,"Standard service, met expectations but didn't exceed them.",support_ticket,2024-05-13T14:17:00,3 +b7b60593dd21,Le produit est arrivé endommagé et le support était inutile. Évaluation: 1/10.,web_form,2024-05-13T18:30:00,2 +e0323f0ac8cb,El producto funciona como se describe. Nada especial.,support_ticket,2024-05-13T15:07:00,3 +fa31b2965135,J'adore ce produit ! Le meilleur achat que j'ai fait.,chat,2024-05-14T21:33:00,5 +7c7942ffd19b,Très satisfait de l'expérience. Hautement recommandé !,support_ticket,2024-05-14T21:41:00,4 +5d5374f10b70,素晴らしい品質です。強くお勧めします!,email,2024-05-15T15:55:00,4 +bace1e085071,Very satisfied with the experience. Highly recommend! Overall rating: 4/5.,web_form,2024-05-15T02:29:00,5 +bcb499bcc7aa,Product works as described. Nothing special.,app_store,2024-05-15T23:41:00,3 +8f2c98d4218a,¡Me encanta este producto! La mejor compra que he hecho.,survey,2024-05-16T19:39:00,4 +c6ffab859e8f,Product works as described. Nothing special.,web_form,2024-05-16T14:21:00,3 +065b8c632edd,Le service client était incroyablement utile et efficace. Note: 5/5.,email,2024-05-16T01:57:00,5 +33be87d724fd,Expérience moyenne. Livraison à temps. Évaluation: 3/10.,chat,2024-05-17T06:29:00,3 +6be00bdb9004,Functional product. Does what it says. Would rate 3/10.,app_store,2024-05-17T00:33:00,3 +8caf1b96d74f,Le produit fonctionne comme décrit. Rien de spécial.,email,2024-05-17T05:46:00,3 +18434f96b724,Mala calidad. Se rompió después de dos semanas.,chat,2024-05-18T12:26:00,2 +1e772c657344,製品は説明通りに動作します。特別なものはありません。,survey,2024-05-18T04:26:00,3 +14e97151cbb4,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",email,2024-05-18T21:08:00,5 +ac485b5e58b9,Experiencia promedio. La entrega fue puntual.,support_ticket,2024-05-19T17:18:00,3 +3590608515ea,Absolutely love this product! Best purchase I've made. Overall rating: 4/5.,survey,2024-05-19T01:44:00,4 +d5dc0e77691e,"The new feature update is amazing, exactly what I needed.",chat,2024-05-20T17:24:00,5 +e62786e9542c,It's okay for the price point. Nothing to complain about.,survey,2024-05-20T01:00:00,3 +f471d8302e37,製品は説明通りに動作します。特別なものはありません。,chat,2024-05-20T17:47:00,3 +36a7285498a9,この製品が大好きです!最高の買い物でした。 評価: 4/5。,survey,2024-05-21T13:17:00,4 +a6216310aa51,Not worth the price. Very disappointing quality.,email,2024-05-21T04:02:00,1 +5780155cd18a,Experiencia promedio. La entrega fue puntual.,twitter,2024-05-21T18:35:00,3 +3771c1095a70,素晴らしい品質です。強くお勧めします! 評価: 4/5。,chat,2024-05-22T11:14:00,5 +11acc24032cd,Really impressed with the build quality and design.,play_store,2024-05-22T20:00:00,5 +d9cb669457ef,El servicio al cliente fue increíblemente útil.,twitter,2024-05-22T21:56:00,5 +8af6fe2e3d27,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Puntuación: 5/5.",chat,2024-05-23T09:57:00,5 +b933d6cd797e,Functional product. Does what it says.,chat,2024-05-23T20:04:00,3 +8bb6d99a448e,"Standard service, met expectations but didn't exceed them.",email,2024-05-24T02:47:00,3 +490f5a8625ac,Terrible experience. Waited 3 weeks for delivery that never came. Would rate 1/10.,web_form,2024-05-24T13:24:00,2 +421bb398eafd,Customer service was incredibly helpful and resolved my issue quickly.,play_store,2024-05-24T20:46:00,4 +5057dad31f32,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",play_store,2024-05-25T09:38:00,5 +121990128874,Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.,email,2024-05-25T17:14:00,5 +07f58e9bb0df,It's okay for the price point. Nothing to complain about.,email,2024-05-25T00:35:00,3 +a933cf020589,¡Me encanta este producto! La mejor compra que he hecho.,twitter,2024-05-26T23:47:00,4 +8ad329d31baa,Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.,app_store,2024-05-26T05:42:00,3 +4dd9b74f599f,Customer service was incredibly helpful and resolved my issue quickly. Overall rating: 5/5.,chat,2024-05-26T21:41:00,4 +c91e26498430,ひどい経験でした。3週間待っても届きませんでした。,survey,2024-05-27T10:39:00,2 +d12cc8db5170,製品は説明通りに動作します。特別なものはありません。 スコア: 3/10。,twitter,2024-05-27T11:08:00,3 +43bbef2f1e6f,Très satisfait de l'expérience. Hautement recommandé !,play_store,2024-05-27T05:41:00,5 +7773474acd14,Product works as described. Nothing special.,app_store,2024-05-28T00:54:00,3 +72702c18d353,Experiencia promedio. La entrega fue puntual.,support_ticket,2024-05-28T04:23:00,3 +d739055b9726,¡Me encanta este producto! La mejor compra que he hecho.,chat,2024-05-29T16:00:00,4 +a82b33b2364b,"Great quality, fast shipping. Will definitely order again.",app_store,2024-05-29T15:22:00,4 +c4627cd85c9c,"The new feature update is amazing, exactly what I needed. Would rate 4/10.",app_store,2024-05-29T14:56:00,4 +1f60c6d4d727,素晴らしい品質です。強くお勧めします!,email,2024-05-30T23:16:00,5 +072c5c1d6a13,J'adore ce produit ! Le meilleur achat que j'ai fait.,chat,2024-05-30T12:58:00,5 +242fc15d6ffa,"Standard service, met expectations but didn't exceed them.",email,2024-05-30T00:32:00,3 +1517985d27df,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Puntuación: 4/5.",support_ticket,2024-05-31T21:14:00,5 +df95218b0d2d,"The new feature update is amazing, exactly what I needed.",support_ticket,2024-05-31T20:53:00,4 +9900e7759b57,El servicio al cliente fue increíblemente útil. Puntuación: 4/5.,email,2024-05-31T08:59:00,5 +ae3d97b9d8ad,Expérience moyenne. Livraison à temps.,web_form,2024-06-01T06:28:00,3 +e300c723cbf5,"Great quality, fast shipping. Will definitely order again.",survey,2024-06-01T05:39:00,4 +d1a826c685e2,El producto funciona como se describe. Nada especial. Puntuación: 3/5.,support_ticket,2024-06-02T06:33:00,3 +e1219697ba88,素晴らしい品質です。強くお勧めします! 評価: 4/5。,app_store,2024-06-02T19:34:00,5 +1e4649494e8a,The team went above and beyond to help me. Outstanding! Score: 5/5.,play_store,2024-06-02T23:58:00,5 +59e70c5d398c,J'adore ce produit ! Le meilleur achat que j'ai fait. Note: 5/5.,app_store,2024-06-03T22:47:00,4 +110dc92921a9,Excellent value for money. Exceeded my expectations.,support_ticket,2024-06-03T03:09:00,5 +1851486dcfa2,It's okay for the price point. Nothing to complain about.,chat,2024-06-03T21:25:00,3 +b4904ca108e9,Muy satisfecho con la experiencia. ¡Lo recomiendo! Puntuación: 5/5.,chat,2024-06-04T21:07:00,5 +d1cabaaad146,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",app_store,2024-06-04T19:44:00,4 +ef3746b1d1a2,Product works as described. Nothing special.,support_ticket,2024-06-04T15:59:00,3 +c24c6da72f48,Absolutely love this product! Best purchase I've made.,survey,2024-06-05T12:39:00,4 +687f221e3dee,Poor quality materials. Broke after two weeks of use.,chat,2024-06-05T16:18:00,1 +3e9ebfbbb39b,Expérience terrible. J'ai attendu 3 semaines pour rien. Évaluation: 1/10.,support_ticket,2024-06-05T12:22:00,1 +72c13722c41e,J'adore ce produit ! Le meilleur achat que j'ai fait.,twitter,2024-06-06T17:18:00,5 +fd59306b1ae1,カスタマーサービスが非常に親切で助かりました。,survey,2024-06-06T18:00:00,4 +a28b9b5eccb8,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",survey,2024-06-07T03:51:00,5 +079f3bc59e94,El producto funciona como se describe. Nada especial.,web_form,2024-06-07T14:43:00,3 +0db2c1f911b2,"The new feature update is amazing, exactly what I needed.",twitter,2024-06-07T07:51:00,5 +7ae50654b135,Very satisfied with the experience. Highly recommend! Overall rating: 4/5.,email,2024-06-08T17:59:00,5 +7a3807dc2020,Der Kundenservice war unglaublich hilfreich. Bewertung: 4/5.,survey,2024-06-08T23:15:00,4 +a5de481553b5,Excellent value for money. Exceeded my expectations. Score: 4/5.,app_store,2024-06-08T21:31:00,4 +57787a914331,Very satisfied with the experience. Highly recommend!,support_ticket,2024-06-09T16:17:00,4 +ee556d2224d9,Très satisfait de l'expérience. Hautement recommandé !,twitter,2024-06-09T01:32:00,5 +b4dc9d0bd3d9,Really impressed with the build quality and design.,chat,2024-06-09T00:00:00,5 +878c2117c4d9,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Calificación: 5/10.",twitter,2024-06-10T19:21:00,4 +86dbaa244266,Excellent value for money. Exceeded my expectations.,web_form,2024-06-10T12:17:00,4 +7e548aa56f4d,Le produit fonctionne comme décrit. Rien de spécial.,support_ticket,2024-06-11T07:35:00,3 +8ea6dfdaac77,この製品が大好きです!最高の買い物でした。,support_ticket,2024-06-11T00:11:00,5 +d9475fac1fae,Das Produkt funktioniert wie beschrieben. Nichts Besonderes.,app_store,2024-06-11T09:16:00,3 +a13566e1668a,Worst customer service I've ever encountered.,web_form,2024-06-12T20:06:00,2 +ace332d6c3b8,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",twitter,2024-06-12T13:07:00,4 +a0e253c884d2,It's okay for the price point. Nothing to complain about.,web_form,2024-06-12T15:49:00,3 +ff25c26bfd16,The software crashes constantly. Very frustrating.,twitter,2024-06-13T20:51:00,2 +4e3f46de5fbd,Average experience. Delivery was on time.,survey,2024-06-13T10:20:00,3 +124c46968421,Excellent value for money. Exceeded my expectations.,support_ticket,2024-06-13T16:28:00,4 +e4df0d9d1acb,Average experience. Delivery was on time.,play_store,2024-06-14T23:44:00,3 +5c0ef4a958e7,Really impressed with the build quality and design.,support_ticket,2024-06-14T00:49:00,4 +465dd702ec31,The team went above and beyond to help me. Outstanding!,survey,2024-06-14T07:47:00,5 +996ee71a2644,Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe. Note: 5/10.,survey,2024-06-15T17:59:00,5 +ebcd9c32c2a6,Le produit fonctionne comme décrit. Rien de spécial.,support_ticket,2024-06-15T14:40:00,3 +a8393d640f33,Customer service was incredibly helpful and resolved my issue quickly.,survey,2024-06-16T11:26:00,4 +2a1ba35ee8cd,El servicio al cliente fue increíblemente útil.,app_store,2024-06-16T15:34:00,5 +a1e0925b2831,"Excellente qualité, livraison rapide. Je recommande vivement !",twitter,2024-06-16T00:35:00,4 +c8cae4d4ad37,Average experience. Delivery was on time. Would rate 3/10.,chat,2024-06-17T02:27:00,3 +1bdaadfd3324,Mala calidad. Se rompió después de dos semanas.,email,2024-06-17T02:11:00,1 +dda4878e3eac,Mala calidad. Se rompió después de dos semanas.,twitter,2024-06-17T15:12:00,1 +10b1d9086a4a,El servicio al cliente fue increíblemente útil.,email,2024-06-18T09:17:00,5 +edc3a7cdd2a3,Really impressed with the build quality and design. Would rate 5/10.,app_store,2024-06-18T14:44:00,5 +c856ddf1fe22,製品は説明通りに動作します。特別なものはありません。 評価: 3/5。,survey,2024-06-18T06:57:00,3 +4f90d45e5c9f,Le service client était incroyablement utile et efficace.,app_store,2024-06-19T14:15:00,4 +f05290b914ca,Poor quality materials. Broke after two weeks of use.,twitter,2024-06-19T21:19:00,1 +0d7a07c75daa,El producto llegó dañado y el soporte no ayudó. Puntuación: 1/5.,play_store,2024-06-20T21:13:00,2 +69152cbaec4f,Very satisfied with the experience. Highly recommend!,chat,2024-06-20T12:50:00,4 +d444eb504b6a,Qualité médiocre. Cassé après deux semaines d'utilisation.,play_store,2024-06-20T11:49:00,1 +9c980249696d,"Excellente qualité, livraison rapide. Je recommande vivement !",app_store,2024-06-21T20:03:00,5 +4f9e1eeea954,Le produit est arrivé endommagé et le support était inutile.,twitter,2024-06-21T10:35:00,1 +c96542d7c843,"Great quality, fast shipping. Will definitely order again. Score: 4/5.",web_form,2024-06-21T09:34:00,4 +027765270430,Really impressed with the build quality and design. Would rate 4/10.,survey,2024-06-22T09:52:00,5 +6121ca8f872b,Muy satisfecho con la experiencia. ¡Lo recomiendo!,twitter,2024-06-22T00:39:00,4 +07c2908a3633,Qualité médiocre. Cassé après deux semaines d'utilisation.,web_form,2024-06-22T06:33:00,2 +e9efb0b27602,¡Me encanta este producto! La mejor compra que he hecho. Puntuación: 4/5.,play_store,2024-06-23T01:12:00,5 +5ea983b64b10,Muy satisfecho con la experiencia. ¡Lo recomiendo!,survey,2024-06-23T16:39:00,4 +162c104bcfa7,J'adore ce produit ! Le meilleur achat que j'ai fait.,web_form,2024-06-23T06:41:00,4 +a8e091488caa,Functional product. Does what it says.,twitter,2024-06-24T13:52:00,3 +ebd01bcb41d3,"Excellente qualité, livraison rapide. Je recommande vivement ! Note: 4/5.",chat,2024-06-24T17:24:00,4 +3ae192e2c3ae,ひどい経験でした。3週間待っても届きませんでした。,twitter,2024-06-25T20:36:00,2 +54729aa27653,Average experience. Delivery was on time.,email,2024-06-25T16:21:00,3 +cac2f6371e53,Experiencia promedio. La entrega fue puntual.,support_ticket,2024-06-25T13:48:00,3 +761cbb4a85d5,Worst customer service I've ever encountered.,support_ticket,2024-06-26T14:28:00,1 +188492cb059b,El producto funciona como se describe. Nada especial.,survey,2024-06-26T09:00:00,3 +5fe5909e7053,It's okay for the price point. Nothing to complain about.,email,2024-06-26T16:35:00,3 +8623f34647f1,Very satisfied with the experience. Highly recommend!,chat,2024-06-27T18:15:00,5 +443844832f8a,"The new feature update is amazing, exactly what I needed.",web_form,2024-06-27T23:20:00,4 +fcfcc04a776a,Le produit fonctionne comme décrit. Rien de spécial.,support_ticket,2024-06-27T09:02:00,3 +1676a3c79ef9,"Standard service, met expectations but didn't exceed them.",chat,2024-06-28T03:55:00,3 +d1d26b71afe9,"Excelente calidad, envío rápido. Definitivamente pediré de nuevo.",chat,2024-06-28T04:20:00,4 diff --git a/demo_data/demo_feedback.json b/demo_data/demo_feedback.json new file mode 100644 index 0000000000000000000000000000000000000000..b453f63817f79d0496e3cfef6d80c500290511c9 --- /dev/null +++ b/demo_data/demo_feedback.json @@ -0,0 +1,3502 @@ +[ + { + "id": "0550d633e0fc", + "text": "Worst customer service I've ever encountered. Score: 1/5.", + "source": "app_store", + "timestamp": "2024-01-01T13:02:00", + "rating": 1 + }, + { + "id": "173ef1403ddb", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "support_ticket", + "timestamp": "2024-01-01T22:34:00", + "rating": 4 + }, + { + "id": "e6b5dff85469", + "text": "Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.", + "source": "support_ticket", + "timestamp": "2024-01-01T05:44:00", + "rating": 5 + }, + { + "id": "1a1d71db74d0", + "text": "カスタマーサービスが非常に親切で助かりました。 スコア: 5/10。", + "source": "app_store", + "timestamp": "2024-01-02T19:16:00", + "rating": 5 + }, + { + "id": "e5668ef2ba52", + "text": "Functional product. Does what it says. Overall rating: 3/5.", + "source": "survey", + "timestamp": "2024-01-02T21:14:00", + "rating": 3 + }, + { + "id": "d2ab38a62733", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "twitter", + "timestamp": "2024-01-02T11:10:00", + "rating": 3 + }, + { + "id": "1e7e92790b1b", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "email", + "timestamp": "2024-01-03T19:40:00", + "rating": 1 + }, + { + "id": "1dfe556ae589", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "app_store", + "timestamp": "2024-01-03T01:14:00", + "rating": 5 + }, + { + "id": "96a99d0e0c89", + "text": "Excellent value for money. Exceeded my expectations. Overall rating: 5/5.", + "source": "chat", + "timestamp": "2024-01-03T08:08:00", + "rating": 5 + }, + { + "id": "7b11f298ec63", + "text": "Average experience. Delivery was on time.", + "source": "web_form", + "timestamp": "2024-01-04T04:32:00", + "rating": 3 + }, + { + "id": "f88afbed6282", + "text": "Average experience. Delivery was on time.", + "source": "play_store", + "timestamp": "2024-01-04T13:38:00", + "rating": 3 + }, + { + "id": "e897b47af36f", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "play_store", + "timestamp": "2024-01-04T00:43:00", + "rating": 5 + }, + { + "id": "a23f0c3ca118", + "text": "Really impressed with the build quality and design. Score: 5/5.", + "source": "chat", + "timestamp": "2024-01-05T09:53:00", + "rating": 4 + }, + { + "id": "dabb18cc10d7", + "text": "El producto funciona como se describe. Nada especial.", + "source": "play_store", + "timestamp": "2024-01-05T15:01:00", + "rating": 3 + }, + { + "id": "c5236a2867ef", + "text": "Customer service was incredibly helpful and resolved my issue quickly. Overall rating: 5/5.", + "source": "email", + "timestamp": "2024-01-06T21:30:00", + "rating": 5 + }, + { + "id": "89d5feeb2dbe", + "text": "製品は説明通りに動作します。特別なものはありません。", + "source": "twitter", + "timestamp": "2024-01-06T12:42:00", + "rating": 3 + }, + { + "id": "bc23021bfd04", + "text": "Excellent value for money. Exceeded my expectations. Overall rating: 4/5.", + "source": "app_store", + "timestamp": "2024-01-06T22:40:00", + "rating": 4 + }, + { + "id": "606cc9cf0904", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "web_form", + "timestamp": "2024-01-07T08:42:00", + "rating": 3 + }, + { + "id": "8296a3f28b29", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "support_ticket", + "timestamp": "2024-01-07T15:51:00", + "rating": 1 + }, + { + "id": "3a739e818bfc", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "play_store", + "timestamp": "2024-01-07T23:03:00", + "rating": 4 + }, + { + "id": "670c149d339a", + "text": "Das Produkt kam beschädigt an und der Support war nutzlos. Note: 2/10.", + "source": "web_form", + "timestamp": "2024-01-08T05:17:00", + "rating": 1 + }, + { + "id": "54e348820065", + "text": "Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert! Bewertung: 4/5.", + "source": "web_form", + "timestamp": "2024-01-08T13:31:00", + "rating": 4 + }, + { + "id": "5327d77f7b35", + "text": "Product works as described. Nothing special.", + "source": "web_form", + "timestamp": "2024-01-08T08:59:00", + "rating": 3 + }, + { + "id": "3393cd2932cc", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "chat", + "timestamp": "2024-01-09T15:09:00", + "rating": 1 + }, + { + "id": "b927e7bdc05f", + "text": "El producto funciona como se describe. Nada especial.", + "source": "web_form", + "timestamp": "2024-01-09T01:03:00", + "rating": 3 + }, + { + "id": "888b9177995c", + "text": "Average experience. Delivery was on time. Overall rating: 3/5.", + "source": "app_store", + "timestamp": "2024-01-10T18:15:00", + "rating": 3 + }, + { + "id": "d81bdf9f2357", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "twitter", + "timestamp": "2024-01-10T06:42:00", + "rating": 2 + }, + { + "id": "da135c9331b5", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "web_form", + "timestamp": "2024-01-10T02:00:00", + "rating": 4 + }, + { + "id": "8fd0b472807f", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "chat", + "timestamp": "2024-01-11T02:56:00", + "rating": 5 + }, + { + "id": "92af5b48a7c7", + "text": "Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert!", + "source": "app_store", + "timestamp": "2024-01-11T20:33:00", + "rating": 5 + }, + { + "id": "b2c2c0cc6bbb", + "text": "Product works as described. Nothing special.", + "source": "email", + "timestamp": "2024-01-11T23:35:00", + "rating": 3 + }, + { + "id": "b634861b9043", + "text": "Le produit est arrivé endommagé et le support était inutile.", + "source": "web_form", + "timestamp": "2024-01-12T08:32:00", + "rating": 1 + }, + { + "id": "1d1538f28fd5", + "text": "Very satisfied with the experience. Highly recommend! Overall rating: 5/5.", + "source": "support_ticket", + "timestamp": "2024-01-12T23:28:00", + "rating": 4 + }, + { + "id": "86c5d6ce1088", + "text": "Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.", + "source": "email", + "timestamp": "2024-01-12T11:37:00", + "rating": 5 + }, + { + "id": "a0a53f1616dd", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "chat", + "timestamp": "2024-01-13T06:43:00", + "rating": 4 + }, + { + "id": "778d0f82ac8f", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "chat", + "timestamp": "2024-01-13T23:09:00", + "rating": 3 + }, + { + "id": "fb224973241b", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "chat", + "timestamp": "2024-01-13T13:51:00", + "rating": 3 + }, + { + "id": "4ec684fa590c", + "text": "Product works as described. Nothing special.", + "source": "web_form", + "timestamp": "2024-01-14T07:12:00", + "rating": 3 + }, + { + "id": "e800bce99714", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Note: 3/10.", + "source": "survey", + "timestamp": "2024-01-14T02:49:00", + "rating": 3 + }, + { + "id": "343d312dd42b", + "text": "Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis.", + "source": "survey", + "timestamp": "2024-01-15T03:56:00", + "rating": 2 + }, + { + "id": "36f59b9ff17f", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "play_store", + "timestamp": "2024-01-15T10:27:00", + "rating": 5 + }, + { + "id": "b0d51f1b0512", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "chat", + "timestamp": "2024-01-15T17:43:00", + "rating": 5 + }, + { + "id": "aae9126e971c", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "play_store", + "timestamp": "2024-01-16T21:54:00", + "rating": 5 + }, + { + "id": "e8dc1b963a6f", + "text": "Worst customer service I've ever encountered.", + "source": "support_ticket", + "timestamp": "2024-01-16T06:26:00", + "rating": 2 + }, + { + "id": "46e6d7b29beb", + "text": "Der Kundenservice war unglaublich hilfreich. Note: 5/10.", + "source": "chat", + "timestamp": "2024-01-16T14:43:00", + "rating": 5 + }, + { + "id": "f7c6833d7219", + "text": "Not worth the price. Very disappointing quality. Overall rating: 2/5.", + "source": "app_store", + "timestamp": "2024-01-17T04:01:00", + "rating": 2 + }, + { + "id": "c531f9434103", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "chat", + "timestamp": "2024-01-17T20:36:00", + "rating": 2 + }, + { + "id": "163491235d98", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "support_ticket", + "timestamp": "2024-01-17T03:49:00", + "rating": 4 + }, + { + "id": "a259a173d317", + "text": "Expérience moyenne. Livraison à temps. Note: 3/5.", + "source": "twitter", + "timestamp": "2024-01-18T14:42:00", + "rating": 3 + }, + { + "id": "f400e865ee2f", + "text": "El producto funciona como se describe. Nada especial.", + "source": "survey", + "timestamp": "2024-01-18T15:28:00", + "rating": 3 + }, + { + "id": "a6f2276573c5", + "text": "El producto funciona como se describe. Nada especial. Calificación: 3/10.", + "source": "play_store", + "timestamp": "2024-01-19T10:20:00", + "rating": 3 + }, + { + "id": "51757444a3b3", + "text": "Le service client était incroyablement utile et efficace. Évaluation: 5/10.", + "source": "support_ticket", + "timestamp": "2024-01-19T01:13:00", + "rating": 5 + }, + { + "id": "7d35f60f1579", + "text": "Expérience moyenne. Livraison à temps.", + "source": "support_ticket", + "timestamp": "2024-01-19T11:19:00", + "rating": 3 + }, + { + "id": "bf1be5d15e60", + "text": "Really impressed with the build quality and design. Would rate 5/10.", + "source": "app_store", + "timestamp": "2024-01-20T04:39:00", + "rating": 5 + }, + { + "id": "54063714b971", + "text": "Really impressed with the build quality and design. Overall rating: 5/5.", + "source": "twitter", + "timestamp": "2024-01-20T14:20:00", + "rating": 5 + }, + { + "id": "9a502b79557d", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "app_store", + "timestamp": "2024-01-20T15:01:00", + "rating": 3 + }, + { + "id": "8a0b4fdaa6c3", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation. Note: 1/5.", + "source": "chat", + "timestamp": "2024-01-21T19:09:00", + "rating": 2 + }, + { + "id": "140d1db71e91", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "twitter", + "timestamp": "2024-01-21T08:49:00", + "rating": 1 + }, + { + "id": "9975ccf52bc0", + "text": "It's okay for the price point. Nothing to complain about. Would rate 3/10.", + "source": "play_store", + "timestamp": "2024-01-21T22:12:00", + "rating": 3 + }, + { + "id": "bc467cbf5958", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "app_store", + "timestamp": "2024-01-22T18:50:00", + "rating": 2 + }, + { + "id": "a0336571974a", + "text": "Not worth the price. Very disappointing quality. Would rate 2/10.", + "source": "web_form", + "timestamp": "2024-01-22T11:40:00", + "rating": 2 + }, + { + "id": "c3f31a575d3d", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "web_form", + "timestamp": "2024-01-22T17:49:00", + "rating": 5 + }, + { + "id": "90c35decddb2", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "web_form", + "timestamp": "2024-01-23T02:17:00", + "rating": 3 + }, + { + "id": "94e74a08a7bf", + "text": "Excellente qualité, livraison rapide. Je recommande vivement ! Note: 5/5.", + "source": "survey", + "timestamp": "2024-01-23T11:51:00", + "rating": 5 + }, + { + "id": "dfe9e2a10c94", + "text": "Expérience moyenne. Livraison à temps.", + "source": "play_store", + "timestamp": "2024-01-24T16:12:00", + "rating": 3 + }, + { + "id": "b6fb94eb1892", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "app_store", + "timestamp": "2024-01-24T15:28:00", + "rating": 4 + }, + { + "id": "c8b70629ff7e", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "twitter", + "timestamp": "2024-01-24T11:30:00", + "rating": 5 + }, + { + "id": "01f86c46c244", + "text": "Very satisfied with the experience. Highly recommend! Overall rating: 4/5.", + "source": "email", + "timestamp": "2024-01-25T23:34:00", + "rating": 4 + }, + { + "id": "f56d52e51b18", + "text": "Worst customer service I've ever encountered.", + "source": "chat", + "timestamp": "2024-01-25T06:18:00", + "rating": 1 + }, + { + "id": "dfd3e9757cbc", + "text": "Le service client était incroyablement utile et efficace. Évaluation: 4/10.", + "source": "app_store", + "timestamp": "2024-01-25T03:55:00", + "rating": 5 + }, + { + "id": "4d976b6f6a88", + "text": "Absolutely love this product! Best purchase I've made. Would rate 5/10.", + "source": "app_store", + "timestamp": "2024-01-26T15:04:00", + "rating": 4 + }, + { + "id": "bf4450bf2dd7", + "text": "製品は説明通りに動作します。特別なものはありません。", + "source": "support_ticket", + "timestamp": "2024-01-26T03:35:00", + "rating": 3 + }, + { + "id": "a1099b2043a5", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "play_store", + "timestamp": "2024-01-26T19:03:00", + "rating": 4 + }, + { + "id": "6539bc5b1089", + "text": "The new feature update is amazing, exactly what I needed. Would rate 4/10.", + "source": "web_form", + "timestamp": "2024-01-27T14:44:00", + "rating": 5 + }, + { + "id": "733c121b417e", + "text": "Très satisfait de l'expérience. Hautement recommandé ! Évaluation: 4/10.", + "source": "chat", + "timestamp": "2024-01-27T03:34:00", + "rating": 4 + }, + { + "id": "ac1cca4b7bc4", + "text": "Average experience. Delivery was on time.", + "source": "survey", + "timestamp": "2024-01-28T19:47:00", + "rating": 3 + }, + { + "id": "2060c03dd652", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "web_form", + "timestamp": "2024-01-28T16:34:00", + "rating": 5 + }, + { + "id": "24c8a0d9693f", + "text": "Ausgezeichnete Qualität, schneller Versand. Sehr empfehlenswert!", + "source": "play_store", + "timestamp": "2024-01-28T08:01:00", + "rating": 4 + }, + { + "id": "f4867eaece55", + "text": "Expérience moyenne. Livraison à temps.", + "source": "web_form", + "timestamp": "2024-01-29T05:30:00", + "rating": 3 + }, + { + "id": "3c19c87c4a10", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "support_ticket", + "timestamp": "2024-01-29T15:22:00", + "rating": 3 + }, + { + "id": "ed52a1a6d205", + "text": "Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis.", + "source": "support_ticket", + "timestamp": "2024-01-29T15:18:00", + "rating": 2 + }, + { + "id": "4f7928992601", + "text": "Customer service was incredibly helpful and resolved my issue quickly.", + "source": "app_store", + "timestamp": "2024-01-30T12:55:00", + "rating": 5 + }, + { + "id": "f198df0da3a0", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo!", + "source": "survey", + "timestamp": "2024-01-30T01:13:00", + "rating": 4 + }, + { + "id": "dc1b9aa72d14", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait. Note: 4/5.", + "source": "play_store", + "timestamp": "2024-01-30T17:26:00", + "rating": 4 + }, + { + "id": "40946150cc51", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "survey", + "timestamp": "2024-01-31T22:18:00", + "rating": 5 + }, + { + "id": "2beccf55e2df", + "text": "Functional product. Does what it says. Would rate 3/10.", + "source": "survey", + "timestamp": "2024-01-31T13:21:00", + "rating": 3 + }, + { + "id": "dd7733e046a7", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "email", + "timestamp": "2024-01-31T15:55:00", + "rating": 2 + }, + { + "id": "0843fb436b8d", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo!", + "source": "chat", + "timestamp": "2024-02-01T10:55:00", + "rating": 4 + }, + { + "id": "75baf2ad5cf2", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "support_ticket", + "timestamp": "2024-02-01T15:45:00", + "rating": 5 + }, + { + "id": "b78ef5099494", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "web_form", + "timestamp": "2024-02-02T14:06:00", + "rating": 4 + }, + { + "id": "6d6a2a6b90f8", + "text": "Le service client était incroyablement utile et efficace. Note: 5/5.", + "source": "support_ticket", + "timestamp": "2024-02-02T10:54:00", + "rating": 5 + }, + { + "id": "5ad7a5d5d629", + "text": "Product works as described. Nothing special. Would rate 3/10.", + "source": "play_store", + "timestamp": "2024-02-02T03:48:00", + "rating": 3 + }, + { + "id": "628981826b8b", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "twitter", + "timestamp": "2024-02-03T01:18:00", + "rating": 2 + }, + { + "id": "82d9e7af73b4", + "text": "El servicio al cliente fue increíblemente útil. Calificación: 4/10.", + "source": "chat", + "timestamp": "2024-02-03T18:09:00", + "rating": 5 + }, + { + "id": "4d93ae3dd21c", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "email", + "timestamp": "2024-02-03T21:34:00", + "rating": 4 + }, + { + "id": "b676359c603a", + "text": "製品は説明通りに動作します。特別なものはありません。", + "source": "survey", + "timestamp": "2024-02-04T13:44:00", + "rating": 3 + }, + { + "id": "b70f2085da3e", + "text": "Really impressed with the build quality and design. Score: 5/5.", + "source": "support_ticket", + "timestamp": "2024-02-04T09:01:00", + "rating": 5 + }, + { + "id": "5d084a7ecc0b", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "survey", + "timestamp": "2024-02-04T23:31:00", + "rating": 4 + }, + { + "id": "32678c956496", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "email", + "timestamp": "2024-02-05T23:49:00", + "rating": 4 + }, + { + "id": "50250461bc18", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "survey", + "timestamp": "2024-02-05T02:58:00", + "rating": 5 + }, + { + "id": "2cfe16ffcac1", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "email", + "timestamp": "2024-02-06T08:53:00", + "rating": 5 + }, + { + "id": "64b362a374c5", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "email", + "timestamp": "2024-02-06T14:04:00", + "rating": 3 + }, + { + "id": "f8cf2040bdc3", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.", + "source": "survey", + "timestamp": "2024-02-06T14:23:00", + "rating": 3 + }, + { + "id": "c5a4102c0ec3", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "chat", + "timestamp": "2024-02-07T17:20:00", + "rating": 4 + }, + { + "id": "e02fb7dcd6c1", + "text": "Standard service, met expectations but didn't exceed them. Would rate 3/10.", + "source": "chat", + "timestamp": "2024-02-07T03:06:00", + "rating": 3 + }, + { + "id": "d847a34ad2a9", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "support_ticket", + "timestamp": "2024-02-07T23:09:00", + "rating": 4 + }, + { + "id": "9c53a13f87f4", + "text": "ひどい経験でした。3週間待っても届きませんでした。 スコア: 1/10。", + "source": "twitter", + "timestamp": "2024-02-08T07:54:00", + "rating": 2 + }, + { + "id": "2fdecc6a7d5e", + "text": "カスタマーサービスが非常に親切で助かりました。 スコア: 5/10。", + "source": "chat", + "timestamp": "2024-02-08T02:42:00", + "rating": 4 + }, + { + "id": "c5ccf0df4480", + "text": "Average experience. Delivery was on time.", + "source": "chat", + "timestamp": "2024-02-08T06:04:00", + "rating": 3 + }, + { + "id": "6c260231024c", + "text": "Absolutely love this product! Best purchase I've made. Overall rating: 4/5.", + "source": "app_store", + "timestamp": "2024-02-09T03:42:00", + "rating": 4 + }, + { + "id": "c542220e734d", + "text": "Excellente qualité, livraison rapide. Je recommande vivement ! Évaluation: 5/10.", + "source": "play_store", + "timestamp": "2024-02-09T16:07:00", + "rating": 5 + }, + { + "id": "18138fbc57b6", + "text": "El producto funciona como se describe. Nada especial.", + "source": "app_store", + "timestamp": "2024-02-09T07:39:00", + "rating": 3 + }, + { + "id": "075c6e01e4f8", + "text": "Misleading product description. Nothing like advertised.", + "source": "play_store", + "timestamp": "2024-02-10T13:43:00", + "rating": 2 + }, + { + "id": "b9c98afc431e", + "text": "Great quality, fast shipping. Will definitely order again. Score: 5/5.", + "source": "web_form", + "timestamp": "2024-02-10T16:18:00", + "rating": 5 + }, + { + "id": "20b0b9cb0def", + "text": "Functional product. Does what it says.", + "source": "web_form", + "timestamp": "2024-02-11T06:27:00", + "rating": 3 + }, + { + "id": "ebfecc937650", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "twitter", + "timestamp": "2024-02-11T10:27:00", + "rating": 5 + }, + { + "id": "ac1a64402be9", + "text": "Expérience moyenne. Livraison à temps. Note: 3/5.", + "source": "twitter", + "timestamp": "2024-02-11T23:47:00", + "rating": 3 + }, + { + "id": "9cbd8bbf44e9", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "support_ticket", + "timestamp": "2024-02-12T13:22:00", + "rating": 4 + }, + { + "id": "9c56d6667025", + "text": "Poor quality materials. Broke after two weeks of use. Would rate 1/10.", + "source": "play_store", + "timestamp": "2024-02-12T17:23:00", + "rating": 1 + }, + { + "id": "5c1d11a5dd01", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "survey", + "timestamp": "2024-02-12T21:53:00", + "rating": 3 + }, + { + "id": "18ff62b0b951", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "email", + "timestamp": "2024-02-13T05:55:00", + "rating": 5 + }, + { + "id": "8c7784e2e007", + "text": "¡Me encanta este producto! La mejor compra que he hecho. Calificación: 4/10.", + "source": "twitter", + "timestamp": "2024-02-13T10:10:00", + "rating": 5 + }, + { + "id": "f7115d9153c8", + "text": "El producto funciona como se describe. Nada especial.", + "source": "app_store", + "timestamp": "2024-02-13T04:10:00", + "rating": 3 + }, + { + "id": "d68d8f0b4219", + "text": "Really impressed with the build quality and design.", + "source": "play_store", + "timestamp": "2024-02-14T06:48:00", + "rating": 5 + }, + { + "id": "a49854c53956", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "chat", + "timestamp": "2024-02-14T09:02:00", + "rating": 5 + }, + { + "id": "c4d95d92ddee", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "twitter", + "timestamp": "2024-02-15T08:18:00", + "rating": 4 + }, + { + "id": "389f5a9171f1", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "twitter", + "timestamp": "2024-02-15T22:14:00", + "rating": 4 + }, + { + "id": "021d3dbea95a", + "text": "Product works as described. Nothing special.", + "source": "twitter", + "timestamp": "2024-02-15T02:55:00", + "rating": 3 + }, + { + "id": "ce92f5aa1131", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "web_form", + "timestamp": "2024-02-16T10:10:00", + "rating": 1 + }, + { + "id": "247d1dd5de5a", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "support_ticket", + "timestamp": "2024-02-16T16:05:00", + "rating": 5 + }, + { + "id": "04dc02158007", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "chat", + "timestamp": "2024-02-16T03:33:00", + "rating": 5 + }, + { + "id": "b4413ca52dda", + "text": "Das Produkt kam beschädigt an und der Support war nutzlos. Bewertung: 2/5.", + "source": "play_store", + "timestamp": "2024-02-17T19:09:00", + "rating": 2 + }, + { + "id": "a05c3f9d5e29", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo!", + "source": "twitter", + "timestamp": "2024-02-17T18:41:00", + "rating": 5 + }, + { + "id": "b9a9d3e33dc0", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "twitter", + "timestamp": "2024-02-17T18:03:00", + "rating": 4 + }, + { + "id": "fed9888473f7", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "play_store", + "timestamp": "2024-02-18T02:41:00", + "rating": 5 + }, + { + "id": "2ca433075686", + "text": "素晴らしい品質です。強くお勧めします!", + "source": "chat", + "timestamp": "2024-02-18T14:58:00", + "rating": 5 + }, + { + "id": "ac04fd57afc3", + "text": "カスタマーサービスが非常に親切で助かりました。 評価: 5/5。", + "source": "web_form", + "timestamp": "2024-02-18T07:45:00", + "rating": 5 + }, + { + "id": "da14a011772e", + "text": "カスタマーサービスが非常に親切で助かりました。", + "source": "app_store", + "timestamp": "2024-02-19T10:43:00", + "rating": 5 + }, + { + "id": "1cdd5b8c0c40", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo! Puntuación: 4/5.", + "source": "support_ticket", + "timestamp": "2024-02-19T11:19:00", + "rating": 4 + }, + { + "id": "8da171ee35f4", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "play_store", + "timestamp": "2024-02-20T21:44:00", + "rating": 5 + }, + { + "id": "5d5d109b06c0", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "chat", + "timestamp": "2024-02-20T15:12:00", + "rating": 4 + }, + { + "id": "42101be2f892", + "text": "Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.", + "source": "app_store", + "timestamp": "2024-02-20T17:53:00", + "rating": 5 + }, + { + "id": "aee5fd8241b0", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "email", + "timestamp": "2024-02-21T19:51:00", + "rating": 5 + }, + { + "id": "9720c191019c", + "text": "Le service client était incroyablement utile et efficace.", + "source": "web_form", + "timestamp": "2024-02-21T19:18:00", + "rating": 4 + }, + { + "id": "bf2323afae55", + "text": "Der Kundenservice war unglaublich hilfreich.", + "source": "web_form", + "timestamp": "2024-02-21T06:23:00", + "rating": 5 + }, + { + "id": "dd7462f688fe", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "email", + "timestamp": "2024-02-22T06:51:00", + "rating": 3 + }, + { + "id": "a14a777be24b", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "play_store", + "timestamp": "2024-02-22T22:54:00", + "rating": 1 + }, + { + "id": "d5c68a8124fe", + "text": "Functional product. Does what it says. Would rate 3/10.", + "source": "app_store", + "timestamp": "2024-02-22T11:59:00", + "rating": 3 + }, + { + "id": "063783a4c96d", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "app_store", + "timestamp": "2024-02-23T11:14:00", + "rating": 5 + }, + { + "id": "eb64c0add977", + "text": "El producto funciona como se describe. Nada especial. Calificación: 3/10.", + "source": "app_store", + "timestamp": "2024-02-23T14:39:00", + "rating": 3 + }, + { + "id": "23566e75a4a4", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "support_ticket", + "timestamp": "2024-02-24T19:33:00", + "rating": 2 + }, + { + "id": "5f69f5c7df59", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "play_store", + "timestamp": "2024-02-24T19:29:00", + "rating": 2 + }, + { + "id": "302e4e5e6d4e", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "app_store", + "timestamp": "2024-02-24T09:27:00", + "rating": 1 + }, + { + "id": "169f965e3cb6", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "app_store", + "timestamp": "2024-02-25T11:04:00", + "rating": 2 + }, + { + "id": "73c9f76a8afa", + "text": "Excellent value for money. Exceeded my expectations. Would rate 4/10.", + "source": "play_store", + "timestamp": "2024-02-25T07:46:00", + "rating": 5 + }, + { + "id": "7e3b9ab6df95", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "web_form", + "timestamp": "2024-02-25T21:29:00", + "rating": 4 + }, + { + "id": "ac10cdfed3bd", + "text": "Très satisfait de l'expérience. Hautement recommandé ! Note: 4/5.", + "source": "survey", + "timestamp": "2024-02-26T10:59:00", + "rating": 5 + }, + { + "id": "aa424db54c37", + "text": "Le produit est arrivé endommagé et le support était inutile. Note: 2/5.", + "source": "support_ticket", + "timestamp": "2024-02-26T11:27:00", + "rating": 2 + }, + { + "id": "66bf64e93e6c", + "text": "Misleading product description. Nothing like advertised.", + "source": "play_store", + "timestamp": "2024-02-26T16:17:00", + "rating": 1 + }, + { + "id": "77ec32f577db", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "play_store", + "timestamp": "2024-02-27T10:06:00", + "rating": 2 + }, + { + "id": "b79aac27202a", + "text": "El peor servicio al cliente que he experimentado. Puntuación: 2/5.", + "source": "twitter", + "timestamp": "2024-02-27T23:55:00", + "rating": 2 + }, + { + "id": "6caaf83f597c", + "text": "Le produit est arrivé endommagé et le support était inutile. Note: 2/5.", + "source": "web_form", + "timestamp": "2024-02-27T04:54:00", + "rating": 1 + }, + { + "id": "526d8f64594d", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "email", + "timestamp": "2024-02-28T18:02:00", + "rating": 5 + }, + { + "id": "d508b22b559b", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation. Évaluation: 2/10.", + "source": "app_store", + "timestamp": "2024-02-28T07:07:00", + "rating": 2 + }, + { + "id": "7679a8a35fc1", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 2/10。", + "source": "web_form", + "timestamp": "2024-02-29T22:58:00", + "rating": 1 + }, + { + "id": "bc36cbb607e6", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "twitter", + "timestamp": "2024-02-29T14:05:00", + "rating": 5 + }, + { + "id": "ae2f775d7121", + "text": "Worst customer service I've ever encountered.", + "source": "email", + "timestamp": "2024-02-29T22:45:00", + "rating": 1 + }, + { + "id": "2c27fcd963d8", + "text": "Great quality, fast shipping. Will definitely order again. Score: 5/5.", + "source": "web_form", + "timestamp": "2024-03-01T18:23:00", + "rating": 4 + }, + { + "id": "ab62a3f12541", + "text": "Really impressed with the build quality and design.", + "source": "play_store", + "timestamp": "2024-03-01T13:52:00", + "rating": 4 + }, + { + "id": "e5a8841b80fc", + "text": "Not worth the price. Very disappointing quality. Score: 1/5.", + "source": "play_store", + "timestamp": "2024-03-01T01:53:00", + "rating": 2 + }, + { + "id": "126ea062cbd8", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "survey", + "timestamp": "2024-03-02T16:57:00", + "rating": 4 + }, + { + "id": "d0d07e36a0b8", + "text": "カスタマーサービスが非常に親切で助かりました。 評価: 5/5。", + "source": "twitter", + "timestamp": "2024-03-02T05:42:00", + "rating": 4 + }, + { + "id": "4aeee428251e", + "text": "素晴らしい品質です。強くお勧めします! スコア: 4/10。", + "source": "survey", + "timestamp": "2024-03-02T20:34:00", + "rating": 5 + }, + { + "id": "4e085be965de", + "text": "Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis. Note: 1/10.", + "source": "web_form", + "timestamp": "2024-03-03T13:30:00", + "rating": 2 + }, + { + "id": "dc35614e7f48", + "text": "Not worth the price. Very disappointing quality.", + "source": "email", + "timestamp": "2024-03-03T11:25:00", + "rating": 1 + }, + { + "id": "6391048b938d", + "text": "Worst customer service I've ever encountered.", + "source": "app_store", + "timestamp": "2024-03-03T04:06:00", + "rating": 2 + }, + { + "id": "614860c7bc7f", + "text": "Average experience. Delivery was on time.", + "source": "twitter", + "timestamp": "2024-03-04T18:46:00", + "rating": 3 + }, + { + "id": "0ba94e4aae2c", + "text": "El peor servicio al cliente que he experimentado.", + "source": "play_store", + "timestamp": "2024-03-04T15:01:00", + "rating": 2 + }, + { + "id": "2cd21a3b6407", + "text": "It's okay for the price point. Nothing to complain about. Would rate 3/10.", + "source": "play_store", + "timestamp": "2024-03-05T17:00:00", + "rating": 3 + }, + { + "id": "a7771b767fca", + "text": "Expérience moyenne. Livraison à temps.", + "source": "chat", + "timestamp": "2024-03-05T12:03:00", + "rating": 3 + }, + { + "id": "6626e8341b30", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "play_store", + "timestamp": "2024-03-05T19:43:00", + "rating": 4 + }, + { + "id": "d67316930082", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "email", + "timestamp": "2024-03-06T11:48:00", + "rating": 2 + }, + { + "id": "7d73508b37a0", + "text": "The software crashes constantly. Very frustrating.", + "source": "web_form", + "timestamp": "2024-03-06T15:33:00", + "rating": 1 + }, + { + "id": "a6ca4b5e29b7", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "survey", + "timestamp": "2024-03-06T19:47:00", + "rating": 2 + }, + { + "id": "4a8db1e38a00", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "support_ticket", + "timestamp": "2024-03-07T21:07:00", + "rating": 1 + }, + { + "id": "a9c8ba0f3417", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "email", + "timestamp": "2024-03-07T18:30:00", + "rating": 1 + }, + { + "id": "f2affdb0a287", + "text": "Functional product. Does what it says.", + "source": "app_store", + "timestamp": "2024-03-07T03:12:00", + "rating": 3 + }, + { + "id": "5f9d2ff7ec45", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "web_form", + "timestamp": "2024-03-08T19:58:00", + "rating": 1 + }, + { + "id": "a63a8fffc3ed", + "text": "El peor servicio al cliente que he experimentado.", + "source": "web_form", + "timestamp": "2024-03-08T10:11:00", + "rating": 2 + }, + { + "id": "5399e5852a49", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "web_form", + "timestamp": "2024-03-09T22:16:00", + "rating": 1 + }, + { + "id": "bdfb9dacf3d3", + "text": "Worst customer service I've ever encountered.", + "source": "web_form", + "timestamp": "2024-03-09T22:13:00", + "rating": 2 + }, + { + "id": "361d4fb8d236", + "text": "ひどい経験でした。3週間待っても届きませんでした。 評価: 2/5。", + "source": "survey", + "timestamp": "2024-03-09T09:02:00", + "rating": 2 + }, + { + "id": "9acdae4d45dd", + "text": "Expérience moyenne. Livraison à temps. Note: 3/5.", + "source": "chat", + "timestamp": "2024-03-10T19:47:00", + "rating": 3 + }, + { + "id": "2d110d201206", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado. Puntuación: 2/5.", + "source": "email", + "timestamp": "2024-03-10T21:49:00", + "rating": 1 + }, + { + "id": "50076c00863c", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "survey", + "timestamp": "2024-03-10T06:48:00", + "rating": 2 + }, + { + "id": "2b666b3d4da8", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "app_store", + "timestamp": "2024-03-11T07:34:00", + "rating": 2 + }, + { + "id": "2ece8489ed9b", + "text": "Product works as described. Nothing special.", + "source": "web_form", + "timestamp": "2024-03-11T12:50:00", + "rating": 3 + }, + { + "id": "4b4d830d2a88", + "text": "Functional product. Does what it says.", + "source": "web_form", + "timestamp": "2024-03-11T10:18:00", + "rating": 3 + }, + { + "id": "59b23f162bdd", + "text": "Average experience. Delivery was on time.", + "source": "web_form", + "timestamp": "2024-03-12T10:10:00", + "rating": 3 + }, + { + "id": "ec5cbb02072a", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "support_ticket", + "timestamp": "2024-03-12T06:11:00", + "rating": 5 + }, + { + "id": "01805b045f78", + "text": "Le service client était incroyablement utile et efficace.", + "source": "chat", + "timestamp": "2024-03-13T15:20:00", + "rating": 4 + }, + { + "id": "49386ec69c94", + "text": "Worst customer service I've ever encountered.", + "source": "app_store", + "timestamp": "2024-03-13T09:37:00", + "rating": 2 + }, + { + "id": "656f19b49e10", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "survey", + "timestamp": "2024-03-13T05:25:00", + "rating": 2 + }, + { + "id": "f32ecdbf3bf2", + "text": "The software crashes constantly. Very frustrating.", + "source": "twitter", + "timestamp": "2024-03-14T14:47:00", + "rating": 2 + }, + { + "id": "9b0946c02e90", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "play_store", + "timestamp": "2024-03-14T21:48:00", + "rating": 3 + }, + { + "id": "22705c465bbf", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "email", + "timestamp": "2024-03-14T16:12:00", + "rating": 4 + }, + { + "id": "d936f17c9fe1", + "text": "Average experience. Delivery was on time.", + "source": "web_form", + "timestamp": "2024-03-15T15:05:00", + "rating": 3 + }, + { + "id": "11cd45dc8595", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "survey", + "timestamp": "2024-03-15T21:51:00", + "rating": 4 + }, + { + "id": "a426493fdb35", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "twitter", + "timestamp": "2024-03-15T01:27:00", + "rating": 1 + }, + { + "id": "54f59417dfaf", + "text": "Standard service, met expectations but didn't exceed them. Score: 3/5.", + "source": "support_ticket", + "timestamp": "2024-03-16T23:41:00", + "rating": 3 + }, + { + "id": "8807cfab33fa", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "email", + "timestamp": "2024-03-16T21:42:00", + "rating": 1 + }, + { + "id": "9599268f98b3", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "app_store", + "timestamp": "2024-03-16T07:53:00", + "rating": 4 + }, + { + "id": "b32102273fab", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "web_form", + "timestamp": "2024-03-17T07:15:00", + "rating": 1 + }, + { + "id": "db01f92bc44d", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。 評価: 1/5。", + "source": "app_store", + "timestamp": "2024-03-17T02:06:00", + "rating": 1 + }, + { + "id": "5b83380eb993", + "text": "Misleading product description. Nothing like advertised. Score: 1/5.", + "source": "twitter", + "timestamp": "2024-03-18T09:14:00", + "rating": 1 + }, + { + "id": "5f23203cfaf4", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "email", + "timestamp": "2024-03-18T08:03:00", + "rating": 2 + }, + { + "id": "12101320cd4f", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "support_ticket", + "timestamp": "2024-03-18T10:44:00", + "rating": 5 + }, + { + "id": "81e1839285f2", + "text": "Misleading product description. Nothing like advertised. Would rate 2/10.", + "source": "support_ticket", + "timestamp": "2024-03-19T01:35:00", + "rating": 1 + }, + { + "id": "413b7c7fe009", + "text": "Not worth the price. Very disappointing quality.", + "source": "twitter", + "timestamp": "2024-03-19T14:54:00", + "rating": 1 + }, + { + "id": "1733d7255f2c", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "chat", + "timestamp": "2024-03-19T06:51:00", + "rating": 5 + }, + { + "id": "1cc03469e91f", + "text": "Product works as described. Nothing special. Overall rating: 3/5.", + "source": "chat", + "timestamp": "2024-03-20T22:38:00", + "rating": 3 + }, + { + "id": "545d0bb313e0", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "survey", + "timestamp": "2024-03-20T20:22:00", + "rating": 2 + }, + { + "id": "9217d6bb48e9", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "twitter", + "timestamp": "2024-03-20T23:30:00", + "rating": 2 + }, + { + "id": "d5cec0823ea3", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "support_ticket", + "timestamp": "2024-03-21T00:16:00", + "rating": 1 + }, + { + "id": "5c8873f259db", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "twitter", + "timestamp": "2024-03-21T20:43:00", + "rating": 5 + }, + { + "id": "73d70d5125b3", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "twitter", + "timestamp": "2024-03-22T23:50:00", + "rating": 3 + }, + { + "id": "c2e558b70544", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "app_store", + "timestamp": "2024-03-22T02:52:00", + "rating": 2 + }, + { + "id": "bd26ada51971", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "web_form", + "timestamp": "2024-03-22T00:13:00", + "rating": 5 + }, + { + "id": "e564a7c66c93", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "app_store", + "timestamp": "2024-03-23T21:31:00", + "rating": 2 + }, + { + "id": "72db904b73ab", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "support_ticket", + "timestamp": "2024-03-23T09:01:00", + "rating": 4 + }, + { + "id": "f3ec3339069f", + "text": "El producto funciona como se describe. Nada especial. Calificación: 3/10.", + "source": "support_ticket", + "timestamp": "2024-03-23T02:23:00", + "rating": 3 + }, + { + "id": "26eff9c842d7", + "text": "Product works as described. Nothing special.", + "source": "app_store", + "timestamp": "2024-03-24T20:48:00", + "rating": 3 + }, + { + "id": "dc1d6ba29517", + "text": "El producto llegó dañado y el soporte no ayudó.", + "source": "play_store", + "timestamp": "2024-03-24T22:10:00", + "rating": 2 + }, + { + "id": "e7aa1ef201a2", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "chat", + "timestamp": "2024-03-24T01:14:00", + "rating": 2 + }, + { + "id": "d0b623ad00a5", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "app_store", + "timestamp": "2024-03-25T18:42:00", + "rating": 4 + }, + { + "id": "3ae049d27d41", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "support_ticket", + "timestamp": "2024-03-25T22:52:00", + "rating": 4 + }, + { + "id": "89d0c21beda2", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "support_ticket", + "timestamp": "2024-03-25T17:21:00", + "rating": 4 + }, + { + "id": "a375efd736b8", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 1/10。", + "source": "survey", + "timestamp": "2024-03-26T10:01:00", + "rating": 2 + }, + { + "id": "d18ac8e50ba5", + "text": "El peor servicio al cliente que he experimentado.", + "source": "support_ticket", + "timestamp": "2024-03-26T21:37:00", + "rating": 2 + }, + { + "id": "e5cedce66157", + "text": "Le produit fonctionne comme décrit. Rien de spécial. Évaluation: 3/10.", + "source": "play_store", + "timestamp": "2024-03-27T11:06:00", + "rating": 3 + }, + { + "id": "e7f1427704dc", + "text": "Le produit est arrivé endommagé et le support était inutile. Évaluation: 2/10.", + "source": "play_store", + "timestamp": "2024-03-27T03:01:00", + "rating": 1 + }, + { + "id": "b74a85c0071c", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien. Note: 2/5.", + "source": "email", + "timestamp": "2024-03-27T18:33:00", + "rating": 1 + }, + { + "id": "b867a1a840c4", + "text": "Really impressed with the build quality and design. Would rate 4/10.", + "source": "play_store", + "timestamp": "2024-03-28T09:30:00", + "rating": 5 + }, + { + "id": "404c92898c7e", + "text": "Expérience moyenne. Livraison à temps.", + "source": "play_store", + "timestamp": "2024-03-28T06:29:00", + "rating": 3 + }, + { + "id": "be88c46c038e", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "twitter", + "timestamp": "2024-03-28T10:28:00", + "rating": 5 + }, + { + "id": "18b023c1103a", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "twitter", + "timestamp": "2024-03-29T05:25:00", + "rating": 2 + }, + { + "id": "b6a80b36aae3", + "text": "Expérience moyenne. Livraison à temps.", + "source": "play_store", + "timestamp": "2024-03-29T12:23:00", + "rating": 3 + }, + { + "id": "86a1de9c3563", + "text": "El peor servicio al cliente que he experimentado.", + "source": "survey", + "timestamp": "2024-03-29T06:28:00", + "rating": 2 + }, + { + "id": "887449793d2d", + "text": "Worst customer service I've ever encountered. Overall rating: 2/5.", + "source": "web_form", + "timestamp": "2024-03-30T14:38:00", + "rating": 2 + }, + { + "id": "b693dcc2f5c9", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "play_store", + "timestamp": "2024-03-30T17:38:00", + "rating": 1 + }, + { + "id": "fdb477af36db", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "support_ticket", + "timestamp": "2024-03-31T20:43:00", + "rating": 2 + }, + { + "id": "fa057f188161", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "chat", + "timestamp": "2024-03-31T16:35:00", + "rating": 5 + }, + { + "id": "f42ca40c1391", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "app_store", + "timestamp": "2024-03-31T04:22:00", + "rating": 1 + }, + { + "id": "a34f61b66c0b", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。 スコア: 2/10。", + "source": "chat", + "timestamp": "2024-04-01T14:50:00", + "rating": 2 + }, + { + "id": "aa21d354df9f", + "text": "Misleading product description. Nothing like advertised.", + "source": "play_store", + "timestamp": "2024-04-01T09:02:00", + "rating": 2 + }, + { + "id": "4c24c3d87472", + "text": "Le produit est arrivé endommagé et le support était inutile.", + "source": "email", + "timestamp": "2024-04-01T23:15:00", + "rating": 2 + }, + { + "id": "4e19280e0617", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "email", + "timestamp": "2024-04-02T20:11:00", + "rating": 2 + }, + { + "id": "bc4c02db08a7", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "app_store", + "timestamp": "2024-04-02T13:24:00", + "rating": 4 + }, + { + "id": "f3846d7596d4", + "text": "Le produit est arrivé endommagé et le support était inutile.", + "source": "twitter", + "timestamp": "2024-04-02T17:11:00", + "rating": 1 + }, + { + "id": "fb30ab40f582", + "text": "Misleading product description. Nothing like advertised.", + "source": "support_ticket", + "timestamp": "2024-04-03T20:20:00", + "rating": 1 + }, + { + "id": "3cb99c3724bd", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen. Note: 1/10.", + "source": "survey", + "timestamp": "2024-04-03T03:15:00", + "rating": 1 + }, + { + "id": "5b4e15892a35", + "text": "The app is full of bugs. Each update makes it worse.", + "source": "support_ticket", + "timestamp": "2024-04-03T04:36:00", + "rating": 1 + }, + { + "id": "cf9df54106d4", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "email", + "timestamp": "2024-04-04T15:18:00", + "rating": 2 + }, + { + "id": "4fe1947761d1", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "chat", + "timestamp": "2024-04-04T15:08:00", + "rating": 3 + }, + { + "id": "9d053f2d608c", + "text": "Misleading product description. Nothing like advertised.", + "source": "app_store", + "timestamp": "2024-04-05T02:36:00", + "rating": 2 + }, + { + "id": "81f76698d94e", + "text": "Very satisfied with the experience. Highly recommend! Score: 5/5.", + "source": "twitter", + "timestamp": "2024-04-05T03:17:00", + "rating": 4 + }, + { + "id": "322d70094e7b", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "chat", + "timestamp": "2024-04-05T20:48:00", + "rating": 1 + }, + { + "id": "7f607caac54a", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "chat", + "timestamp": "2024-04-06T03:49:00", + "rating": 2 + }, + { + "id": "dc80149104c9", + "text": "Experiencia promedio. La entrega fue puntual. Puntuación: 3/5.", + "source": "survey", + "timestamp": "2024-04-06T18:22:00", + "rating": 3 + }, + { + "id": "dff2acdd415b", + "text": "Functional product. Does what it says. Overall rating: 3/5.", + "source": "email", + "timestamp": "2024-04-06T08:55:00", + "rating": 3 + }, + { + "id": "ba2038d093d8", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "web_form", + "timestamp": "2024-04-07T21:25:00", + "rating": 4 + }, + { + "id": "ab8d1e151121", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "survey", + "timestamp": "2024-04-07T02:25:00", + "rating": 2 + }, + { + "id": "f584e5b57190", + "text": "Product works as described. Nothing special.", + "source": "web_form", + "timestamp": "2024-04-07T23:05:00", + "rating": 3 + }, + { + "id": "59b356c778b5", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "twitter", + "timestamp": "2024-04-08T15:17:00", + "rating": 3 + }, + { + "id": "6a4150e88d00", + "text": "Misleading product description. Nothing like advertised.", + "source": "app_store", + "timestamp": "2024-04-08T21:48:00", + "rating": 1 + }, + { + "id": "c8ea06ef9bce", + "text": "Das Produkt kam beschädigt an und der Support war nutzlos. Bewertung: 2/5.", + "source": "email", + "timestamp": "2024-04-09T13:12:00", + "rating": 1 + }, + { + "id": "91d27827fe8f", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "twitter", + "timestamp": "2024-04-09T08:05:00", + "rating": 3 + }, + { + "id": "2e25ae4c6ca2", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "play_store", + "timestamp": "2024-04-09T02:45:00", + "rating": 1 + }, + { + "id": "02f843e52619", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "app_store", + "timestamp": "2024-04-10T14:00:00", + "rating": 1 + }, + { + "id": "a57a13fde7c1", + "text": "Average experience. Delivery was on time.", + "source": "play_store", + "timestamp": "2024-04-10T07:24:00", + "rating": 3 + }, + { + "id": "5ab13cadd7a8", + "text": "Das Produkt kam beschädigt an und der Support war nutzlos.", + "source": "email", + "timestamp": "2024-04-10T04:41:00", + "rating": 1 + }, + { + "id": "1ad7a81621b8", + "text": "Not worth the price. Very disappointing quality.", + "source": "email", + "timestamp": "2024-04-11T20:47:00", + "rating": 2 + }, + { + "id": "f3a9c5b14225", + "text": "Product works as described. Nothing special. Would rate 3/10.", + "source": "email", + "timestamp": "2024-04-11T10:50:00", + "rating": 3 + }, + { + "id": "fcc9d208c2ca", + "text": "Worst customer service I've ever encountered.", + "source": "survey", + "timestamp": "2024-04-11T22:01:00", + "rating": 1 + }, + { + "id": "580e6abb0f9b", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "support_ticket", + "timestamp": "2024-04-12T07:31:00", + "rating": 1 + }, + { + "id": "2e7f346e325c", + "text": "El producto llegó dañado y el soporte no ayudó.", + "source": "support_ticket", + "timestamp": "2024-04-12T10:08:00", + "rating": 2 + }, + { + "id": "271077900307", + "text": "Misleading product description. Nothing like advertised.", + "source": "play_store", + "timestamp": "2024-04-12T02:01:00", + "rating": 2 + }, + { + "id": "ff393310ba05", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "support_ticket", + "timestamp": "2024-04-13T20:45:00", + "rating": 2 + }, + { + "id": "73d3b30c0681", + "text": "El servicio al cliente fue increíblemente útil. Puntuación: 5/5.", + "source": "support_ticket", + "timestamp": "2024-04-13T17:20:00", + "rating": 4 + }, + { + "id": "cb138b5884f9", + "text": "Le produit est arrivé endommagé et le support était inutile. Évaluation: 2/10.", + "source": "app_store", + "timestamp": "2024-04-14T19:38:00", + "rating": 1 + }, + { + "id": "28c3aea3ee75", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait. Évaluation: 5/10.", + "source": "survey", + "timestamp": "2024-04-14T04:47:00", + "rating": 4 + }, + { + "id": "75df13da99b3", + "text": "Terrible experience. Waited 3 weeks for delivery that never came. Score: 2/5.", + "source": "web_form", + "timestamp": "2024-04-14T11:03:00", + "rating": 2 + }, + { + "id": "10f96758c1a8", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "app_store", + "timestamp": "2024-04-15T03:14:00", + "rating": 1 + }, + { + "id": "03c96e160bc7", + "text": "The app is full of bugs. Each update makes it worse.", + "source": "app_store", + "timestamp": "2024-04-15T05:59:00", + "rating": 1 + }, + { + "id": "9a1f59c27bab", + "text": "Not worth the price. Very disappointing quality. Score: 2/5.", + "source": "app_store", + "timestamp": "2024-04-15T21:57:00", + "rating": 1 + }, + { + "id": "a9015f11104c", + "text": "製品は説明通りに動作します。特別なものはありません。 スコア: 3/10。", + "source": "web_form", + "timestamp": "2024-04-16T02:35:00", + "rating": 3 + }, + { + "id": "12741663077a", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "twitter", + "timestamp": "2024-04-16T07:29:00", + "rating": 1 + }, + { + "id": "14100a5c8d69", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado. Calificación: 1/10.", + "source": "app_store", + "timestamp": "2024-04-16T20:53:00", + "rating": 1 + }, + { + "id": "69252e0bbf8d", + "text": "It's okay for the price point. Nothing to complain about. Overall rating: 3/5.", + "source": "survey", + "timestamp": "2024-04-17T04:45:00", + "rating": 3 + }, + { + "id": "501c386ac0cc", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "play_store", + "timestamp": "2024-04-17T11:29:00", + "rating": 3 + }, + { + "id": "76dc7fa1c3b5", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "survey", + "timestamp": "2024-04-18T04:15:00", + "rating": 2 + }, + { + "id": "2cd63c85fdfb", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "play_store", + "timestamp": "2024-04-18T08:14:00", + "rating": 3 + }, + { + "id": "e09a987e999c", + "text": "El producto funciona como se describe. Nada especial. Calificación: 3/10.", + "source": "twitter", + "timestamp": "2024-04-18T03:34:00", + "rating": 3 + }, + { + "id": "d2db59bbc719", + "text": "Terrible experience. Waited 3 weeks for delivery that never came. Would rate 1/10.", + "source": "chat", + "timestamp": "2024-04-19T04:05:00", + "rating": 2 + }, + { + "id": "b7c8cfe17f76", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "support_ticket", + "timestamp": "2024-04-19T05:01:00", + "rating": 1 + }, + { + "id": "0d98b2509fdb", + "text": "Great quality, fast shipping. Will definitely order again. Overall rating: 4/5.", + "source": "play_store", + "timestamp": "2024-04-19T12:23:00", + "rating": 5 + }, + { + "id": "afb29e245af2", + "text": "Really impressed with the build quality and design.", + "source": "twitter", + "timestamp": "2024-04-20T02:57:00", + "rating": 4 + }, + { + "id": "a92617a33970", + "text": "Functional product. Does what it says.", + "source": "web_form", + "timestamp": "2024-04-20T23:25:00", + "rating": 3 + }, + { + "id": "f01979c7e3dd", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "web_form", + "timestamp": "2024-04-20T08:17:00", + "rating": 1 + }, + { + "id": "9ee8188eec34", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "app_store", + "timestamp": "2024-04-21T17:30:00", + "rating": 2 + }, + { + "id": "4d25d21b5d54", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "app_store", + "timestamp": "2024-04-21T17:50:00", + "rating": 2 + }, + { + "id": "68cec6a195e0", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "support_ticket", + "timestamp": "2024-04-21T16:20:00", + "rating": 1 + }, + { + "id": "62a6a3ef328b", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "web_form", + "timestamp": "2024-04-22T08:03:00", + "rating": 5 + }, + { + "id": "9db15c8adf74", + "text": "Average experience. Delivery was on time. Would rate 3/10.", + "source": "chat", + "timestamp": "2024-04-22T05:11:00", + "rating": 3 + }, + { + "id": "ddd461ac0e83", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "chat", + "timestamp": "2024-04-23T18:09:00", + "rating": 3 + }, + { + "id": "a8995de033da", + "text": "Product arrived damaged and customer support was unhelpful. Overall rating: 1/5.", + "source": "chat", + "timestamp": "2024-04-23T10:58:00", + "rating": 2 + }, + { + "id": "cf0609c1cfcb", + "text": "ひどい経験でした。3週間待っても届きませんでした。 スコア: 1/10。", + "source": "support_ticket", + "timestamp": "2024-04-23T02:23:00", + "rating": 2 + }, + { + "id": "0b46244aad20", + "text": "The software crashes constantly. Very frustrating.", + "source": "web_form", + "timestamp": "2024-04-24T13:50:00", + "rating": 1 + }, + { + "id": "973f981ca8ee", + "text": "Mala calidad. Se rompió después de dos semanas. Calificación: 2/10.", + "source": "app_store", + "timestamp": "2024-04-24T04:11:00", + "rating": 1 + }, + { + "id": "a7fcf4aa8eb4", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "web_form", + "timestamp": "2024-04-24T03:59:00", + "rating": 2 + }, + { + "id": "bc6817c71804", + "text": "Not worth the price. Very disappointing quality. Overall rating: 2/5.", + "source": "chat", + "timestamp": "2024-04-25T08:13:00", + "rating": 2 + }, + { + "id": "eecc004d8f5d", + "text": "Product arrived damaged and customer support was unhelpful. Would rate 2/10.", + "source": "support_ticket", + "timestamp": "2024-04-25T15:56:00", + "rating": 2 + }, + { + "id": "822bd418d317", + "text": "The new feature update is amazing, exactly what I needed. Overall rating: 4/5.", + "source": "play_store", + "timestamp": "2024-04-25T10:16:00", + "rating": 4 + }, + { + "id": "5a608e492af1", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "web_form", + "timestamp": "2024-04-26T02:21:00", + "rating": 2 + }, + { + "id": "2e2078c0d315", + "text": "Standard service, met expectations but didn't exceed them. Overall rating: 3/5.", + "source": "survey", + "timestamp": "2024-04-26T13:44:00", + "rating": 3 + }, + { + "id": "9992b76025c3", + "text": "Misleading product description. Nothing like advertised. Overall rating: 1/5.", + "source": "web_form", + "timestamp": "2024-04-27T09:52:00", + "rating": 1 + }, + { + "id": "9b9167666561", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "play_store", + "timestamp": "2024-04-27T15:54:00", + "rating": 5 + }, + { + "id": "cacc430f759e", + "text": "Schreckliche Erfahrung. 3 Wochen gewartet ohne Ergebnis. Note: 1/10.", + "source": "web_form", + "timestamp": "2024-04-27T11:11:00", + "rating": 2 + }, + { + "id": "4d2f814dbaa1", + "text": "Worst customer service I've ever encountered.", + "source": "play_store", + "timestamp": "2024-04-28T04:48:00", + "rating": 2 + }, + { + "id": "f494ca790f4b", + "text": "Functional product. Does what it says. Score: 3/5.", + "source": "web_form", + "timestamp": "2024-04-28T21:55:00", + "rating": 3 + }, + { + "id": "15b6dbaade39", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "email", + "timestamp": "2024-04-28T16:51:00", + "rating": 2 + }, + { + "id": "d2d7176a11e3", + "text": "製品が破損して届きました。サポートも役に立ちませんでした。", + "source": "support_ticket", + "timestamp": "2024-04-29T04:02:00", + "rating": 2 + }, + { + "id": "a50f1450851b", + "text": "Das Produkt kam beschädigt an und der Support war nutzlos.", + "source": "play_store", + "timestamp": "2024-04-29T08:20:00", + "rating": 2 + }, + { + "id": "e1e0d4d62c30", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "support_ticket", + "timestamp": "2024-04-29T00:50:00", + "rating": 2 + }, + { + "id": "40efc5eb438f", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "play_store", + "timestamp": "2024-04-30T18:49:00", + "rating": 5 + }, + { + "id": "8242512ea8e0", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "email", + "timestamp": "2024-04-30T15:02:00", + "rating": 3 + }, + { + "id": "50d18a94c572", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo! Calificación: 5/10.", + "source": "twitter", + "timestamp": "2024-04-30T08:57:00", + "rating": 4 + }, + { + "id": "7d217031f852", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "play_store", + "timestamp": "2024-05-01T22:43:00", + "rating": 3 + }, + { + "id": "48b0b5be77e4", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien. Évaluation: 1/10.", + "source": "support_ticket", + "timestamp": "2024-05-01T06:58:00", + "rating": 2 + }, + { + "id": "ea09df97e12f", + "text": "El producto funciona como se describe. Nada especial. Puntuación: 3/5.", + "source": "chat", + "timestamp": "2024-05-02T06:56:00", + "rating": 3 + }, + { + "id": "1d220675c4a0", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "web_form", + "timestamp": "2024-05-02T08:23:00", + "rating": 5 + }, + { + "id": "c61f1802c573", + "text": "Misleading product description. Nothing like advertised.", + "source": "web_form", + "timestamp": "2024-05-02T05:55:00", + "rating": 2 + }, + { + "id": "5cddb2374982", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen.", + "source": "app_store", + "timestamp": "2024-05-03T02:18:00", + "rating": 1 + }, + { + "id": "33af4f2edc2b", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "chat", + "timestamp": "2024-05-03T20:14:00", + "rating": 2 + }, + { + "id": "c9cd5f4f05f3", + "text": "Schlechte Qualität. Nach zwei Wochen kaputt gegangen. Bewertung: 1/5.", + "source": "survey", + "timestamp": "2024-05-03T08:38:00", + "rating": 2 + }, + { + "id": "c52d31c416e8", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.", + "source": "twitter", + "timestamp": "2024-05-04T00:32:00", + "rating": 3 + }, + { + "id": "2730ef05e7fa", + "text": "Not worth the price. Very disappointing quality.", + "source": "survey", + "timestamp": "2024-05-04T20:20:00", + "rating": 2 + }, + { + "id": "10165b116545", + "text": "Experiencia terrible. Esperé 3 semanas sin resultado.", + "source": "twitter", + "timestamp": "2024-05-04T19:25:00", + "rating": 1 + }, + { + "id": "5fb507265130", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "app_store", + "timestamp": "2024-05-05T12:24:00", + "rating": 2 + }, + { + "id": "623e6a443af5", + "text": "Misleading product description. Nothing like advertised.", + "source": "survey", + "timestamp": "2024-05-05T21:22:00", + "rating": 1 + }, + { + "id": "c87974c8b9a9", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "email", + "timestamp": "2024-05-05T17:48:00", + "rating": 5 + }, + { + "id": "9d1cbde7fbf4", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "survey", + "timestamp": "2024-05-06T10:23:00", + "rating": 5 + }, + { + "id": "7cf995866844", + "text": "Functional product. Does what it says.", + "source": "web_form", + "timestamp": "2024-05-06T19:04:00", + "rating": 3 + }, + { + "id": "250d6266ed15", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "chat", + "timestamp": "2024-05-07T04:08:00", + "rating": 3 + }, + { + "id": "843d41f74d2d", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Note: 3/10.", + "source": "twitter", + "timestamp": "2024-05-07T20:11:00", + "rating": 3 + }, + { + "id": "0f21bc3ec57b", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "play_store", + "timestamp": "2024-05-07T05:31:00", + "rating": 4 + }, + { + "id": "7d02a4ee50ef", + "text": "Misleading product description. Nothing like advertised.", + "source": "chat", + "timestamp": "2024-05-08T01:34:00", + "rating": 1 + }, + { + "id": "170b42fb7a19", + "text": "Le produit fonctionne comme décrit. Rien de spécial. Évaluation: 3/10.", + "source": "play_store", + "timestamp": "2024-05-08T09:08:00", + "rating": 3 + }, + { + "id": "ff7859d5527c", + "text": "Der Kundenservice war unglaublich hilfreich.", + "source": "twitter", + "timestamp": "2024-05-08T14:46:00", + "rating": 5 + }, + { + "id": "061fdaedff5f", + "text": "カスタマーサービスが非常に親切で助かりました。", + "source": "support_ticket", + "timestamp": "2024-05-09T05:58:00", + "rating": 4 + }, + { + "id": "1891677b908a", + "text": "この製品が大好きです!最高の買い物でした。", + "source": "email", + "timestamp": "2024-05-09T18:32:00", + "rating": 4 + }, + { + "id": "0647b75ffb0c", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien.", + "source": "app_store", + "timestamp": "2024-05-09T08:49:00", + "rating": 1 + }, + { + "id": "150aa21654f3", + "text": "Terrible experience. Waited 3 weeks for delivery that never came.", + "source": "survey", + "timestamp": "2024-05-10T12:07:00", + "rating": 1 + }, + { + "id": "aa3faf994758", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "survey", + "timestamp": "2024-05-10T21:06:00", + "rating": 4 + }, + { + "id": "1aa8fb020790", + "text": "Functional product. Does what it says. Overall rating: 3/5.", + "source": "survey", + "timestamp": "2024-05-11T19:12:00", + "rating": 3 + }, + { + "id": "150dcc6d227d", + "text": "Product arrived damaged and customer support was unhelpful.", + "source": "web_form", + "timestamp": "2024-05-11T06:44:00", + "rating": 1 + }, + { + "id": "8d7976b8ccfa", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "email", + "timestamp": "2024-05-11T21:31:00", + "rating": 4 + }, + { + "id": "6f858c35935c", + "text": "Worst customer service I've ever encountered.", + "source": "survey", + "timestamp": "2024-05-12T14:18:00", + "rating": 1 + }, + { + "id": "848dcd163944", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "twitter", + "timestamp": "2024-05-12T11:51:00", + "rating": 5 + }, + { + "id": "bd84f35ea401", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait. Évaluation: 4/10.", + "source": "survey", + "timestamp": "2024-05-12T14:39:00", + "rating": 4 + }, + { + "id": "1f2d3e7e9212", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "support_ticket", + "timestamp": "2024-05-13T14:17:00", + "rating": 3 + }, + { + "id": "b7b60593dd21", + "text": "Le produit est arrivé endommagé et le support était inutile. Évaluation: 1/10.", + "source": "web_form", + "timestamp": "2024-05-13T18:30:00", + "rating": 2 + }, + { + "id": "e0323f0ac8cb", + "text": "El producto funciona como se describe. Nada especial.", + "source": "support_ticket", + "timestamp": "2024-05-13T15:07:00", + "rating": 3 + }, + { + "id": "fa31b2965135", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "chat", + "timestamp": "2024-05-14T21:33:00", + "rating": 5 + }, + { + "id": "7c7942ffd19b", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "support_ticket", + "timestamp": "2024-05-14T21:41:00", + "rating": 4 + }, + { + "id": "5d5374f10b70", + "text": "素晴らしい品質です。強くお勧めします!", + "source": "email", + "timestamp": "2024-05-15T15:55:00", + "rating": 4 + }, + { + "id": "bace1e085071", + "text": "Very satisfied with the experience. Highly recommend! Overall rating: 4/5.", + "source": "web_form", + "timestamp": "2024-05-15T02:29:00", + "rating": 5 + }, + { + "id": "bcb499bcc7aa", + "text": "Product works as described. Nothing special.", + "source": "app_store", + "timestamp": "2024-05-15T23:41:00", + "rating": 3 + }, + { + "id": "8f2c98d4218a", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "survey", + "timestamp": "2024-05-16T19:39:00", + "rating": 4 + }, + { + "id": "c6ffab859e8f", + "text": "Product works as described. Nothing special.", + "source": "web_form", + "timestamp": "2024-05-16T14:21:00", + "rating": 3 + }, + { + "id": "065b8c632edd", + "text": "Le service client était incroyablement utile et efficace. Note: 5/5.", + "source": "email", + "timestamp": "2024-05-16T01:57:00", + "rating": 5 + }, + { + "id": "33be87d724fd", + "text": "Expérience moyenne. Livraison à temps. Évaluation: 3/10.", + "source": "chat", + "timestamp": "2024-05-17T06:29:00", + "rating": 3 + }, + { + "id": "6be00bdb9004", + "text": "Functional product. Does what it says. Would rate 3/10.", + "source": "app_store", + "timestamp": "2024-05-17T00:33:00", + "rating": 3 + }, + { + "id": "8caf1b96d74f", + "text": "Le produit fonctionne comme décrit. Rien de spécial.", + "source": "email", + "timestamp": "2024-05-17T05:46:00", + "rating": 3 + }, + { + "id": "18434f96b724", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "chat", + "timestamp": "2024-05-18T12:26:00", + "rating": 2 + }, + { + "id": "1e772c657344", + "text": "製品は説明通りに動作します。特別なものはありません。", + "source": "survey", + "timestamp": "2024-05-18T04:26:00", + "rating": 3 + }, + { + "id": "14e97151cbb4", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "email", + "timestamp": "2024-05-18T21:08:00", + "rating": 5 + }, + { + "id": "ac485b5e58b9", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "support_ticket", + "timestamp": "2024-05-19T17:18:00", + "rating": 3 + }, + { + "id": "3590608515ea", + "text": "Absolutely love this product! Best purchase I've made. Overall rating: 4/5.", + "source": "survey", + "timestamp": "2024-05-19T01:44:00", + "rating": 4 + }, + { + "id": "d5dc0e77691e", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "chat", + "timestamp": "2024-05-20T17:24:00", + "rating": 5 + }, + { + "id": "e62786e9542c", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "survey", + "timestamp": "2024-05-20T01:00:00", + "rating": 3 + }, + { + "id": "f471d8302e37", + "text": "製品は説明通りに動作します。特別なものはありません。", + "source": "chat", + "timestamp": "2024-05-20T17:47:00", + "rating": 3 + }, + { + "id": "36a7285498a9", + "text": "この製品が大好きです!最高の買い物でした。 評価: 4/5。", + "source": "survey", + "timestamp": "2024-05-21T13:17:00", + "rating": 4 + }, + { + "id": "a6216310aa51", + "text": "Not worth the price. Very disappointing quality.", + "source": "email", + "timestamp": "2024-05-21T04:02:00", + "rating": 1 + }, + { + "id": "5780155cd18a", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "twitter", + "timestamp": "2024-05-21T18:35:00", + "rating": 3 + }, + { + "id": "3771c1095a70", + "text": "素晴らしい品質です。強くお勧めします! 評価: 4/5。", + "source": "chat", + "timestamp": "2024-05-22T11:14:00", + "rating": 5 + }, + { + "id": "11acc24032cd", + "text": "Really impressed with the build quality and design.", + "source": "play_store", + "timestamp": "2024-05-22T20:00:00", + "rating": 5 + }, + { + "id": "d9cb669457ef", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "twitter", + "timestamp": "2024-05-22T21:56:00", + "rating": 5 + }, + { + "id": "8af6fe2e3d27", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Puntuación: 5/5.", + "source": "chat", + "timestamp": "2024-05-23T09:57:00", + "rating": 5 + }, + { + "id": "b933d6cd797e", + "text": "Functional product. Does what it says.", + "source": "chat", + "timestamp": "2024-05-23T20:04:00", + "rating": 3 + }, + { + "id": "8bb6d99a448e", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "email", + "timestamp": "2024-05-24T02:47:00", + "rating": 3 + }, + { + "id": "490f5a8625ac", + "text": "Terrible experience. Waited 3 weeks for delivery that never came. Would rate 1/10.", + "source": "web_form", + "timestamp": "2024-05-24T13:24:00", + "rating": 2 + }, + { + "id": "421bb398eafd", + "text": "Customer service was incredibly helpful and resolved my issue quickly.", + "source": "play_store", + "timestamp": "2024-05-24T20:46:00", + "rating": 4 + }, + { + "id": "5057dad31f32", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "play_store", + "timestamp": "2024-05-25T09:38:00", + "rating": 5 + }, + { + "id": "121990128874", + "text": "Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe.", + "source": "email", + "timestamp": "2024-05-25T17:14:00", + "rating": 5 + }, + { + "id": "07f58e9bb0df", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "email", + "timestamp": "2024-05-25T00:35:00", + "rating": 3 + }, + { + "id": "a933cf020589", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "twitter", + "timestamp": "2024-05-26T23:47:00", + "rating": 4 + }, + { + "id": "8ad329d31baa", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes. Bewertung: 3/5.", + "source": "app_store", + "timestamp": "2024-05-26T05:42:00", + "rating": 3 + }, + { + "id": "4dd9b74f599f", + "text": "Customer service was incredibly helpful and resolved my issue quickly. Overall rating: 5/5.", + "source": "chat", + "timestamp": "2024-05-26T21:41:00", + "rating": 4 + }, + { + "id": "c91e26498430", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "survey", + "timestamp": "2024-05-27T10:39:00", + "rating": 2 + }, + { + "id": "d12cc8db5170", + "text": "製品は説明通りに動作します。特別なものはありません。 スコア: 3/10。", + "source": "twitter", + "timestamp": "2024-05-27T11:08:00", + "rating": 3 + }, + { + "id": "43bbef2f1e6f", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "play_store", + "timestamp": "2024-05-27T05:41:00", + "rating": 5 + }, + { + "id": "7773474acd14", + "text": "Product works as described. Nothing special.", + "source": "app_store", + "timestamp": "2024-05-28T00:54:00", + "rating": 3 + }, + { + "id": "72702c18d353", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "support_ticket", + "timestamp": "2024-05-28T04:23:00", + "rating": 3 + }, + { + "id": "d739055b9726", + "text": "¡Me encanta este producto! La mejor compra que he hecho.", + "source": "chat", + "timestamp": "2024-05-29T16:00:00", + "rating": 4 + }, + { + "id": "a82b33b2364b", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "app_store", + "timestamp": "2024-05-29T15:22:00", + "rating": 4 + }, + { + "id": "c4627cd85c9c", + "text": "The new feature update is amazing, exactly what I needed. Would rate 4/10.", + "source": "app_store", + "timestamp": "2024-05-29T14:56:00", + "rating": 4 + }, + { + "id": "1f60c6d4d727", + "text": "素晴らしい品質です。強くお勧めします!", + "source": "email", + "timestamp": "2024-05-30T23:16:00", + "rating": 5 + }, + { + "id": "072c5c1d6a13", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "chat", + "timestamp": "2024-05-30T12:58:00", + "rating": 5 + }, + { + "id": "242fc15d6ffa", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "email", + "timestamp": "2024-05-30T00:32:00", + "rating": 3 + }, + { + "id": "1517985d27df", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Puntuación: 4/5.", + "source": "support_ticket", + "timestamp": "2024-05-31T21:14:00", + "rating": 5 + }, + { + "id": "df95218b0d2d", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "support_ticket", + "timestamp": "2024-05-31T20:53:00", + "rating": 4 + }, + { + "id": "9900e7759b57", + "text": "El servicio al cliente fue increíblemente útil. Puntuación: 4/5.", + "source": "email", + "timestamp": "2024-05-31T08:59:00", + "rating": 5 + }, + { + "id": "ae3d97b9d8ad", + "text": "Expérience moyenne. Livraison à temps.", + "source": "web_form", + "timestamp": "2024-06-01T06:28:00", + "rating": 3 + }, + { + "id": "e300c723cbf5", + "text": "Great quality, fast shipping. Will definitely order again.", + "source": "survey", + "timestamp": "2024-06-01T05:39:00", + "rating": 4 + }, + { + "id": "d1a826c685e2", + "text": "El producto funciona como se describe. Nada especial. Puntuación: 3/5.", + "source": "support_ticket", + "timestamp": "2024-06-02T06:33:00", + "rating": 3 + }, + { + "id": "e1219697ba88", + "text": "素晴らしい品質です。強くお勧めします! 評価: 4/5。", + "source": "app_store", + "timestamp": "2024-06-02T19:34:00", + "rating": 5 + }, + { + "id": "1e4649494e8a", + "text": "The team went above and beyond to help me. Outstanding! Score: 5/5.", + "source": "play_store", + "timestamp": "2024-06-02T23:58:00", + "rating": 5 + }, + { + "id": "59e70c5d398c", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait. Note: 5/5.", + "source": "app_store", + "timestamp": "2024-06-03T22:47:00", + "rating": 4 + }, + { + "id": "110dc92921a9", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "support_ticket", + "timestamp": "2024-06-03T03:09:00", + "rating": 5 + }, + { + "id": "1851486dcfa2", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "chat", + "timestamp": "2024-06-03T21:25:00", + "rating": 3 + }, + { + "id": "b4904ca108e9", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo! Puntuación: 5/5.", + "source": "chat", + "timestamp": "2024-06-04T21:07:00", + "rating": 5 + }, + { + "id": "d1cabaaad146", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "app_store", + "timestamp": "2024-06-04T19:44:00", + "rating": 4 + }, + { + "id": "ef3746b1d1a2", + "text": "Product works as described. Nothing special.", + "source": "support_ticket", + "timestamp": "2024-06-04T15:59:00", + "rating": 3 + }, + { + "id": "c24c6da72f48", + "text": "Absolutely love this product! Best purchase I've made.", + "source": "survey", + "timestamp": "2024-06-05T12:39:00", + "rating": 4 + }, + { + "id": "687f221e3dee", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "chat", + "timestamp": "2024-06-05T16:18:00", + "rating": 1 + }, + { + "id": "3e9ebfbbb39b", + "text": "Expérience terrible. J'ai attendu 3 semaines pour rien. Évaluation: 1/10.", + "source": "support_ticket", + "timestamp": "2024-06-05T12:22:00", + "rating": 1 + }, + { + "id": "72c13722c41e", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "twitter", + "timestamp": "2024-06-06T17:18:00", + "rating": 5 + }, + { + "id": "fd59306b1ae1", + "text": "カスタマーサービスが非常に親切で助かりました。", + "source": "survey", + "timestamp": "2024-06-06T18:00:00", + "rating": 4 + }, + { + "id": "a28b9b5eccb8", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "survey", + "timestamp": "2024-06-07T03:51:00", + "rating": 5 + }, + { + "id": "079f3bc59e94", + "text": "El producto funciona como se describe. Nada especial.", + "source": "web_form", + "timestamp": "2024-06-07T14:43:00", + "rating": 3 + }, + { + "id": "0db2c1f911b2", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "twitter", + "timestamp": "2024-06-07T07:51:00", + "rating": 5 + }, + { + "id": "7ae50654b135", + "text": "Very satisfied with the experience. Highly recommend! Overall rating: 4/5.", + "source": "email", + "timestamp": "2024-06-08T17:59:00", + "rating": 5 + }, + { + "id": "7a3807dc2020", + "text": "Der Kundenservice war unglaublich hilfreich. Bewertung: 4/5.", + "source": "survey", + "timestamp": "2024-06-08T23:15:00", + "rating": 4 + }, + { + "id": "a5de481553b5", + "text": "Excellent value for money. Exceeded my expectations. Score: 4/5.", + "source": "app_store", + "timestamp": "2024-06-08T21:31:00", + "rating": 4 + }, + { + "id": "57787a914331", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "support_ticket", + "timestamp": "2024-06-09T16:17:00", + "rating": 4 + }, + { + "id": "ee556d2224d9", + "text": "Très satisfait de l'expérience. Hautement recommandé !", + "source": "twitter", + "timestamp": "2024-06-09T01:32:00", + "rating": 5 + }, + { + "id": "b4dc9d0bd3d9", + "text": "Really impressed with the build quality and design.", + "source": "chat", + "timestamp": "2024-06-09T00:00:00", + "rating": 5 + }, + { + "id": "878c2117c4d9", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo. Calificación: 5/10.", + "source": "twitter", + "timestamp": "2024-06-10T19:21:00", + "rating": 4 + }, + { + "id": "86dbaa244266", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "web_form", + "timestamp": "2024-06-10T12:17:00", + "rating": 4 + }, + { + "id": "7e548aa56f4d", + "text": "Le produit fonctionne comme décrit. Rien de spécial.", + "source": "support_ticket", + "timestamp": "2024-06-11T07:35:00", + "rating": 3 + }, + { + "id": "8ea6dfdaac77", + "text": "この製品が大好きです!最高の買い物でした。", + "source": "support_ticket", + "timestamp": "2024-06-11T00:11:00", + "rating": 5 + }, + { + "id": "d9475fac1fae", + "text": "Das Produkt funktioniert wie beschrieben. Nichts Besonderes.", + "source": "app_store", + "timestamp": "2024-06-11T09:16:00", + "rating": 3 + }, + { + "id": "a13566e1668a", + "text": "Worst customer service I've ever encountered.", + "source": "web_form", + "timestamp": "2024-06-12T20:06:00", + "rating": 2 + }, + { + "id": "ace332d6c3b8", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "twitter", + "timestamp": "2024-06-12T13:07:00", + "rating": 4 + }, + { + "id": "a0e253c884d2", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "web_form", + "timestamp": "2024-06-12T15:49:00", + "rating": 3 + }, + { + "id": "ff25c26bfd16", + "text": "The software crashes constantly. Very frustrating.", + "source": "twitter", + "timestamp": "2024-06-13T20:51:00", + "rating": 2 + }, + { + "id": "4e3f46de5fbd", + "text": "Average experience. Delivery was on time.", + "source": "survey", + "timestamp": "2024-06-13T10:20:00", + "rating": 3 + }, + { + "id": "124c46968421", + "text": "Excellent value for money. Exceeded my expectations.", + "source": "support_ticket", + "timestamp": "2024-06-13T16:28:00", + "rating": 4 + }, + { + "id": "e4df0d9d1acb", + "text": "Average experience. Delivery was on time.", + "source": "play_store", + "timestamp": "2024-06-14T23:44:00", + "rating": 3 + }, + { + "id": "5c0ef4a958e7", + "text": "Really impressed with the build quality and design.", + "source": "support_ticket", + "timestamp": "2024-06-14T00:49:00", + "rating": 4 + }, + { + "id": "465dd702ec31", + "text": "The team went above and beyond to help me. Outstanding!", + "source": "survey", + "timestamp": "2024-06-14T07:47:00", + "rating": 5 + }, + { + "id": "996ee71a2644", + "text": "Ich liebe dieses Produkt! Bester Kauf den ich je gemacht habe. Note: 5/10.", + "source": "survey", + "timestamp": "2024-06-15T17:59:00", + "rating": 5 + }, + { + "id": "ebcd9c32c2a6", + "text": "Le produit fonctionne comme décrit. Rien de spécial.", + "source": "support_ticket", + "timestamp": "2024-06-15T14:40:00", + "rating": 3 + }, + { + "id": "a8393d640f33", + "text": "Customer service was incredibly helpful and resolved my issue quickly.", + "source": "survey", + "timestamp": "2024-06-16T11:26:00", + "rating": 4 + }, + { + "id": "2a1ba35ee8cd", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "app_store", + "timestamp": "2024-06-16T15:34:00", + "rating": 5 + }, + { + "id": "a1e0925b2831", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "twitter", + "timestamp": "2024-06-16T00:35:00", + "rating": 4 + }, + { + "id": "c8cae4d4ad37", + "text": "Average experience. Delivery was on time. Would rate 3/10.", + "source": "chat", + "timestamp": "2024-06-17T02:27:00", + "rating": 3 + }, + { + "id": "1bdaadfd3324", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "email", + "timestamp": "2024-06-17T02:11:00", + "rating": 1 + }, + { + "id": "dda4878e3eac", + "text": "Mala calidad. Se rompió después de dos semanas.", + "source": "twitter", + "timestamp": "2024-06-17T15:12:00", + "rating": 1 + }, + { + "id": "10b1d9086a4a", + "text": "El servicio al cliente fue increíblemente útil.", + "source": "email", + "timestamp": "2024-06-18T09:17:00", + "rating": 5 + }, + { + "id": "edc3a7cdd2a3", + "text": "Really impressed with the build quality and design. Would rate 5/10.", + "source": "app_store", + "timestamp": "2024-06-18T14:44:00", + "rating": 5 + }, + { + "id": "c856ddf1fe22", + "text": "製品は説明通りに動作します。特別なものはありません。 評価: 3/5。", + "source": "survey", + "timestamp": "2024-06-18T06:57:00", + "rating": 3 + }, + { + "id": "4f90d45e5c9f", + "text": "Le service client était incroyablement utile et efficace.", + "source": "app_store", + "timestamp": "2024-06-19T14:15:00", + "rating": 4 + }, + { + "id": "f05290b914ca", + "text": "Poor quality materials. Broke after two weeks of use.", + "source": "twitter", + "timestamp": "2024-06-19T21:19:00", + "rating": 1 + }, + { + "id": "0d7a07c75daa", + "text": "El producto llegó dañado y el soporte no ayudó. Puntuación: 1/5.", + "source": "play_store", + "timestamp": "2024-06-20T21:13:00", + "rating": 2 + }, + { + "id": "69152cbaec4f", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "chat", + "timestamp": "2024-06-20T12:50:00", + "rating": 4 + }, + { + "id": "d444eb504b6a", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "play_store", + "timestamp": "2024-06-20T11:49:00", + "rating": 1 + }, + { + "id": "9c980249696d", + "text": "Excellente qualité, livraison rapide. Je recommande vivement !", + "source": "app_store", + "timestamp": "2024-06-21T20:03:00", + "rating": 5 + }, + { + "id": "4f9e1eeea954", + "text": "Le produit est arrivé endommagé et le support était inutile.", + "source": "twitter", + "timestamp": "2024-06-21T10:35:00", + "rating": 1 + }, + { + "id": "c96542d7c843", + "text": "Great quality, fast shipping. Will definitely order again. Score: 4/5.", + "source": "web_form", + "timestamp": "2024-06-21T09:34:00", + "rating": 4 + }, + { + "id": "027765270430", + "text": "Really impressed with the build quality and design. Would rate 4/10.", + "source": "survey", + "timestamp": "2024-06-22T09:52:00", + "rating": 5 + }, + { + "id": "6121ca8f872b", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo!", + "source": "twitter", + "timestamp": "2024-06-22T00:39:00", + "rating": 4 + }, + { + "id": "07c2908a3633", + "text": "Qualité médiocre. Cassé après deux semaines d'utilisation.", + "source": "web_form", + "timestamp": "2024-06-22T06:33:00", + "rating": 2 + }, + { + "id": "e9efb0b27602", + "text": "¡Me encanta este producto! La mejor compra que he hecho. Puntuación: 4/5.", + "source": "play_store", + "timestamp": "2024-06-23T01:12:00", + "rating": 5 + }, + { + "id": "5ea983b64b10", + "text": "Muy satisfecho con la experiencia. ¡Lo recomiendo!", + "source": "survey", + "timestamp": "2024-06-23T16:39:00", + "rating": 4 + }, + { + "id": "162c104bcfa7", + "text": "J'adore ce produit ! Le meilleur achat que j'ai fait.", + "source": "web_form", + "timestamp": "2024-06-23T06:41:00", + "rating": 4 + }, + { + "id": "a8e091488caa", + "text": "Functional product. Does what it says.", + "source": "twitter", + "timestamp": "2024-06-24T13:52:00", + "rating": 3 + }, + { + "id": "ebd01bcb41d3", + "text": "Excellente qualité, livraison rapide. Je recommande vivement ! Note: 4/5.", + "source": "chat", + "timestamp": "2024-06-24T17:24:00", + "rating": 4 + }, + { + "id": "3ae192e2c3ae", + "text": "ひどい経験でした。3週間待っても届きませんでした。", + "source": "twitter", + "timestamp": "2024-06-25T20:36:00", + "rating": 2 + }, + { + "id": "54729aa27653", + "text": "Average experience. Delivery was on time.", + "source": "email", + "timestamp": "2024-06-25T16:21:00", + "rating": 3 + }, + { + "id": "cac2f6371e53", + "text": "Experiencia promedio. La entrega fue puntual.", + "source": "support_ticket", + "timestamp": "2024-06-25T13:48:00", + "rating": 3 + }, + { + "id": "761cbb4a85d5", + "text": "Worst customer service I've ever encountered.", + "source": "support_ticket", + "timestamp": "2024-06-26T14:28:00", + "rating": 1 + }, + { + "id": "188492cb059b", + "text": "El producto funciona como se describe. Nada especial.", + "source": "survey", + "timestamp": "2024-06-26T09:00:00", + "rating": 3 + }, + { + "id": "5fe5909e7053", + "text": "It's okay for the price point. Nothing to complain about.", + "source": "email", + "timestamp": "2024-06-26T16:35:00", + "rating": 3 + }, + { + "id": "8623f34647f1", + "text": "Very satisfied with the experience. Highly recommend!", + "source": "chat", + "timestamp": "2024-06-27T18:15:00", + "rating": 5 + }, + { + "id": "443844832f8a", + "text": "The new feature update is amazing, exactly what I needed.", + "source": "web_form", + "timestamp": "2024-06-27T23:20:00", + "rating": 4 + }, + { + "id": "fcfcc04a776a", + "text": "Le produit fonctionne comme décrit. Rien de spécial.", + "source": "support_ticket", + "timestamp": "2024-06-27T09:02:00", + "rating": 3 + }, + { + "id": "1676a3c79ef9", + "text": "Standard service, met expectations but didn't exceed them.", + "source": "chat", + "timestamp": "2024-06-28T03:55:00", + "rating": 3 + }, + { + "id": "d1d26b71afe9", + "text": "Excelente calidad, envío rápido. Definitivamente pediré de nuevo.", + "source": "chat", + "timestamp": "2024-06-28T04:20:00", + "rating": 4 + } +] \ No newline at end of file diff --git a/demo_data/feedback_feb2024.csv b/demo_data/feedback_feb2024.csv new file mode 100644 index 0000000000000000000000000000000000000000..8d4389573e28315ff7f15b10c5b4303b355a482d --- /dev/null +++ b/demo_data/feedback_feb2024.csv @@ -0,0 +1,51 @@ +text,source,timestamp,language +La actualización más reciente mejoró mucho el rendimiento. ¡Genial!,twitter,2024-02-01 06:43:48,Spanish +Deux semaines sans réponse du support. C'est inadmissible.,support_ticket,2024-02-01 16:28:13,French +Seit zwei Wochen keine Antwort vom Support. Das ist inakzeptabel.,support_ticket,2024-02-02 08:09:53,German +サポートに問い合わせて2週間経ちますが、まだ返答がありません。,twitter,2024-02-03 11:01:03,Japanese +Seit zwei Wochen keine Antwort vom Support. Das ist inakzeptabel.,chat,2024-02-04 16:42:07,German +Das neue Design ist gewöhnungsbedürftig. Bin noch unentschieden.,twitter,2024-02-05 12:11:30,German +"Viel zu viele Werbeanzeigen, selbst in der bezahlten Version. Nicht empfehlenswert.",chat,2024-02-05 13:47:30,German +Das letzte Update hat viele nützliche Funktionen gebracht. Sehr zufrieden!,support_ticket,2024-02-06 04:50:05,German +El nuevo diseño es diferente. Aún no sé si me gusta más que el anterior.,twitter,2024-02-06 12:17:41,Spanish +The app crashes every time I try to export a report. Completely unusable.,chat,2024-02-07 02:12:47,English +The app crashes every time I try to export a report. Completely unusable.,chat,2024-02-07 10:48:49,English +"Decent product overall. Nothing exceptional, but it gets the job done.",support_ticket,2024-02-08 16:04:05,English +La qualité du produit est bien en dessous de ce qui était annoncé.,support_ticket,2024-02-08 16:52:03,French +The app crashes every time I try to export a report. Completely unusable.,chat,2024-02-09 07:41:30,English +I was charged twice for my subscription and nobody seems to care.,twitter,2024-02-09 13:46:33,English +Das Produkt war bei Lieferung beschädigt und die Rückgabe ist kompliziert.,chat,2024-02-09 20:24:45,German +"Viel zu viele Werbeanzeigen, selbst in der bezahlten Version. Nicht empfehlenswert.",twitter,2024-02-09 23:15:43,German +Der Kundenservice hat mir innerhalb einer Stunde geholfen. Top!,twitter,2024-02-10 00:03:06,German +最新のアップデート後、バッテリーの消耗が激しくなりました。非常に困っています。,twitter,2024-02-10 02:29:06,Japanese +"Die App stürzt ständig ab, seit dem letzten Update. Sehr enttäuschend.",support_ticket,2024-02-10 16:09:18,German +Perdí todos mis datos tras la migración. Estoy muy decepcionado.,twitter,2024-02-10 17:17:27,Spanish +The new design is different. Not sure if I prefer it over the old one yet.,twitter,2024-02-10 22:38:40,English +アプリが頻繁にクラッシュして仕事になりません。早急に修正してください。,chat,2024-02-13 00:27:02,Japanese +Der Kundenservice hat mir innerhalb einer Stunde geholfen. Top!,chat,2024-02-13 12:10:58,German +Works as expected. Would appreciate more customization options in future updates.,chat,2024-02-13 19:28:58,English +"Exactly what I was looking for. Simple, elegant, and powerful.",chat,2024-02-14 06:13:41,English +基本的な機能は問題なく使えますが、もう少し高度な機能が欲しいです。,support_ticket,2024-02-14 06:57:59,Japanese +La qualité du produit est bien en dessous de ce qui était annoncé.,twitter,2024-02-15 04:01:04,French +I was charged twice for my subscription and nobody seems to care.,support_ticket,2024-02-16 01:51:51,English +アプリが頻繁にクラッシュして仕事になりません。早急に修正してください。,support_ticket,2024-02-16 14:11:36,Japanese +Connection drops constantly. I can't rely on this for my business anymore.,support_ticket,2024-02-16 19:43:50,English +Deux semaines sans réponse du support. C'est inadmissible.,twitter,2024-02-17 12:05:49,French +Way too many ads. I'm paying for premium and still seeing banner ads everywhere.,support_ticket,2024-02-17 20:05:25,English +基本的な機能は問題なく使えますが、もう少し高度な機能が欲しいです。,support_ticket,2024-02-18 07:12:48,Japanese +Works as expected. Would appreciate more customization options in future updates.,chat,2024-02-18 13:55:16,English +アプリが頻繁にクラッシュして仕事になりません。早急に修正してください。,support_ticket,2024-02-19 09:27:42,Japanese +普通の製品です。特に不満はありませんが、特筆すべき点もありません。,chat,2024-02-19 14:28:16,Japanese +The latest update fixed every issue I had. Developers really listen to feedback.,support_ticket,2024-02-22 11:53:47,English +Das Produkt war bei Lieferung beschädigt und die Rückgabe ist kompliziert.,twitter,2024-02-22 17:38:10,German +Best customer experience I've had in years. The support team truly cares.,chat,2024-02-23 14:57:38,English +"Ganz okay. Nichts Besonderes, aber es erfüllt seinen Zweck.",support_ticket,2024-02-23 20:00:44,German +Rapport qualité-prix imbattable. Je suis client fidèle depuis un an.,chat,2024-02-24 00:26:22,French +"Die App stürzt ständig ab, seit dem letzten Update. Sehr enttäuschend.",twitter,2024-02-24 16:42:15,German +普通の製品です。特に不満はありませんが、特筆すべき点もありません。,twitter,2024-02-26 01:23:17,Japanese +Great value for the price. I've recommended it to all my coworkers.,chat,2024-02-26 01:35:21,English +El servicio al cliente fue excelente. Resolvieron mi problema en minutos.,chat,2024-02-26 13:04:52,Spanish +Honestly disappointed. The features advertised on the website don't actually exist.,chat,2024-02-28 05:42:31,English +I was charged twice for my subscription and nobody seems to care.,twitter,2024-02-28 06:29:34,English +基本的な機能は問題なく使えますが、もう少し高度な機能が欲しいです。,support_ticket,2024-02-29 07:26:20,Japanese +"Viel zu viele Werbeanzeigen, selbst in der bezahlten Version. Nicht empfehlenswert.",twitter,2024-02-29 12:45:41,German diff --git a/demo_data/feedback_jan2024.csv b/demo_data/feedback_jan2024.csv new file mode 100644 index 0000000000000000000000000000000000000000..99632f4622ba4ce9fde7280a099e2a6ec8503b4a --- /dev/null +++ b/demo_data/feedback_jan2024.csv @@ -0,0 +1,51 @@ +text,source,timestamp,language +This product has completely changed how I manage my daily workflow. Five stars!,email,2024-01-01 02:24:52,English +Hervorragende Qualität zum fairen Preis. Kann ich nur weiterempfehlen.,app_store,2024-01-02 19:05:46,German +La mise à jour a vraiment amélioré les performances. Bravo à l'équipe !,survey,2024-01-02 23:15:52,French +Shipping was lightning fast and the packaging was eco-friendly. Impressed!,app_store,2024-01-02 23:38:06,English +Das Produkt war bei Lieferung beschädigt und die Rückgabe ist kompliziert.,email,2024-01-03 01:14:39,German +"The app works fine for basic tasks, but lacks some advanced features I need.",app_store,2024-01-03 02:08:44,English +La mise à jour a vraiment amélioré les performances. Bravo à l'équipe !,app_store,2024-01-03 22:10:42,French +Das neue Design ist gewöhnungsbedürftig. Bin noch unentschieden.,app_store,2024-01-04 23:31:23,German +"Exactly what I was looking for. Simple, elegant, and powerful.",email,2024-01-05 10:17:47,English +Muy contento con mi compra. Lo recomiendo sin dudarlo.,survey,2024-01-06 07:19:37,Spanish +El servicio al cliente fue excelente. Resolvieron mi problema en minutos.,email,2024-01-06 16:30:54,Spanish +Bin absolut begeistert von der App. Läuft stabil und sieht super aus.,app_store,2024-01-06 22:32:34,German +Hervorragende Qualität zum fairen Preis. Kann ich nur weiterempfehlen.,survey,2024-01-06 23:03:45,German +The new design is different. Not sure if I prefer it over the old one yet.,survey,2024-01-07 19:50:30,English +The search function is broken. It returns completely irrelevant results every time.,survey,2024-01-08 19:03:07,English +"L'application fonctionne correctement pour les tâches simples, sans plus.",survey,2024-01-10 06:02:34,French +カスタマーサポートの対応が素晴らしかったです。すぐに問題が解決しました。,survey,2024-01-11 01:15:58,Japanese +Le nouveau design est différent. Je ne suis pas encore sûr de l'apprécier.,survey,2024-01-11 13:05:25,French +Great value for the price. I've recommended it to all my coworkers.,app_store,2024-01-12 11:24:52,English +"Es un producto aceptable. Cumple su función, aunque no destaca en nada.",app_store,2024-01-12 14:11:30,Spanish +Le service client a été irréprochable. Problème résolu en un clin d'œil.,email,2024-01-12 18:18:53,French +カスタマーサポートの対応が素晴らしかったです。すぐに問題が解決しました。,app_store,2024-01-12 18:48:49,Japanese +Le temps de chargement est insupportable. Plusieurs minutes pour ouvrir une page.,email,2024-01-13 08:51:00,French +"Trop de publicités intrusives. J'ai payé pour la version premium, c'est scandaleux.",survey,2024-01-13 09:09:13,French +La qualité du produit est bien en dessous de ce qui était annoncé.,email,2024-01-13 17:51:35,French +J'ai perdu toutes mes données sans aucun avertissement. Très déçu.,survey,2024-01-15 15:40:01,French +"Die App funktioniert gut für grundlegende Aufgaben, aber es fehlen einige Funktionen.",email,2024-01-16 01:19:00,German +After the latest update the battery drain is insane. Please fix this ASAP.,survey,2024-01-16 07:10:32,English +Great value for the price. I've recommended it to all my coworkers.,app_store,2024-01-16 22:31:55,English +I've been using it for a month now. It's okay but I'm still evaluating alternatives.,email,2024-01-17 08:35:20,English +The free tier is generous enough for my needs. Might upgrade soon though!,survey,2024-01-17 16:59:56,English +Really appreciate the attention to detail in the UI. Everything just works.,app_store,2024-01-17 17:11:23,English +La actualización más reciente mejoró mucho el rendimiento. ¡Genial!,app_store,2024-01-18 02:43:05,Spanish +Honestly disappointed. The features advertised on the website don't actually exist.,app_store,2024-01-19 13:36:52,English +Application fantastique ! L'interface est claire et agréable à utiliser.,app_store,2024-01-20 10:49:10,French +Bin absolut begeistert von der App. Läuft stabil und sieht super aus.,app_store,2024-01-21 05:11:44,German +Bin absolut begeistert von der App. Läuft stabil und sieht super aus.,survey,2024-01-21 06:10:14,German +品質が非常に高く、価格以上の価値があります。大満足です。,app_store,2024-01-21 07:38:46,Japanese +"Me encanta esta aplicación, es muy fácil de usar y funciona de maravilla.",survey,2024-01-23 08:13:00,Spanish +"Me encanta esta aplicación, es muy fácil de usar y funciona de maravilla.",email,2024-01-23 13:47:26,Spanish +Bin absolut begeistert von der App. Läuft stabil und sieht super aus.,app_store,2024-01-23 16:32:20,German +Llevo dos semanas esperando respuesta del soporte técnico. Inaceptable.,survey,2024-01-24 09:55:01,Spanish +"Exactly what I was looking for. Simple, elegant, and powerful.",app_store,2024-01-24 23:09:45,English +Best customer experience I've had in years. The support team truly cares.,email,2024-01-26 01:10:05,English +Application fantastique ! L'interface est claire et agréable à utiliser.,app_store,2024-01-26 11:25:15,French +Best customer experience I've had in years. The support team truly cares.,email,2024-01-28 05:46:07,English +Le nouveau design est différent. Je ne suis pas encore sûr de l'apprécier.,email,2024-01-28 21:18:45,French +"Me encanta esta aplicación, es muy fácil de usar y funciona de maravilla.",app_store,2024-01-29 17:13:00,Spanish +"Delivery was on time. Product matches the description, nothing more nothing less.",survey,2024-01-30 00:52:14,English +Le temps de chargement est insupportable. Plusieurs minutes pour ouvrir une page.,email,2024-01-31 03:05:04,French diff --git a/demo_data/feedback_mar2024.csv b/demo_data/feedback_mar2024.csv new file mode 100644 index 0000000000000000000000000000000000000000..57c9b60755cb2d9e16b8c4d02348cb64a8240018 --- /dev/null +++ b/demo_data/feedback_mar2024.csv @@ -0,0 +1,51 @@ +text,source,timestamp,language +"Livraison dans les temps. Le produit correspond à la description, sans surprise.",play_store,2024-03-01 04:23:12,French +Connection drops constantly. I can't rely on this for my business anymore.,email,2024-03-02 14:45:06,English +基本的な機能は問題なく使えますが、もう少し高度な機能が欲しいです。,email,2024-03-03 03:03:07,Japanese +Muy contento con mi compra. Lo recomiendo sin dudarlo.,web_form,2024-03-03 15:41:45,Spanish +It's a solid tool for beginners. Power users might find it a bit limited.,play_store,2024-03-04 06:31:15,English +Deux semaines sans réponse du support. C'est inadmissible.,play_store,2024-03-04 08:25:31,French +Honestly disappointed. The features advertised on the website don't actually exist.,web_form,2024-03-05 06:57:00,English +Absolutely love this app! The interface is so intuitive and responsive.,email,2024-03-05 17:22:20,English +アップデートで動作がさらに快適になりました。開発チームに感謝します。,email,2024-03-05 17:49:31,Japanese +"Livraison dans les temps. Le produit correspond à la description, sans surprise.",email,2024-03-06 02:47:35,French +アップデートで動作がさらに快適になりました。開発チームに感謝します。,email,2024-03-07 03:33:26,Japanese +I've been using it for a month now. It's okay but I'm still evaluating alternatives.,web_form,2024-03-07 16:59:24,English +L'application plante à chaque ouverture depuis la dernière mise à jour.,play_store,2024-03-07 20:52:03,French +Honestly disappointed. The features advertised on the website don't actually exist.,email,2024-03-09 08:56:28,English +"Delivery was on time. Product matches the description, nothing more nothing less.",web_form,2024-03-09 10:09:21,English +Great value for the price. I've recommended it to all my coworkers.,play_store,2024-03-09 15:01:22,English +"Delivery was on time. Product matches the description, nothing more nothing less.",play_store,2024-03-11 03:33:01,English +The search function is broken. It returns completely irrelevant results every time.,web_form,2024-03-13 01:18:34,English +Shipping was lightning fast and the packaging was eco-friendly. Impressed!,play_store,2024-03-13 01:46:14,English +Das Produkt war bei Lieferung beschädigt und die Rückgabe ist kompliziert.,play_store,2024-03-14 12:04:02,German +Producto de gran calidad. Superó todas mis expectativas.,email,2024-03-15 01:35:10,Spanish +J'ai perdu toutes mes données sans aucun avertissement. Très déçu.,web_form,2024-03-16 03:14:33,French +Le temps de chargement est insupportable. Plusieurs minutes pour ouvrir une page.,email,2024-03-16 05:10:15,French +"L'application fonctionne correctement pour les tâches simples, sans plus.",play_store,2024-03-17 12:21:44,French +This product has completely changed how I manage my daily workflow. Five stars!,email,2024-03-17 13:23:16,English +It's a solid tool for beginners. Power users might find it a bit limited.,play_store,2024-03-17 16:26:09,English +The onboarding process was seamless. I was up and running in minutes.,play_store,2024-03-17 19:42:09,English +"Exactly what I was looking for. Simple, elegant, and powerful.",web_form,2024-03-18 11:35:35,English +Le nouveau design est différent. Je ne suis pas encore sûr de l'apprécier.,email,2024-03-19 00:40:37,French +The search function is broken. It returns completely irrelevant results every time.,email,2024-03-19 22:05:11,English +L'application plante à chaque ouverture depuis la dernière mise à jour.,email,2024-03-21 01:46:19,French +新しいデザインは慣れが必要です。前の方が良かったかもしれません。,email,2024-03-21 13:16:17,Japanese +"Delivery was on time. Product matches the description, nothing more nothing less.",email,2024-03-21 23:56:46,English +"Es un producto aceptable. Cumple su función, aunque no destaca en nada.",web_form,2024-03-22 14:42:41,Spanish +The free tier is generous enough for my needs. Might upgrade soon though!,web_form,2024-03-22 21:34:32,English +"Lost all my data after the migration. No warning, no backup option. Furious.",email,2024-03-23 06:27:27,English +The new design is different. Not sure if I prefer it over the old one yet.,web_form,2024-03-23 08:38:07,English +"Trop de publicités intrusives. J'ai payé pour la version premium, c'est scandaleux.",email,2024-03-24 12:04:27,French +The free tier is generous enough for my needs. Might upgrade soon though!,play_store,2024-03-24 18:34:19,English +Shipping was lightning fast and the packaging was eco-friendly. Impressed!,play_store,2024-03-25 17:35:13,English +The UI redesign is awful. Everything I need is now buried under three menus.,email,2024-03-25 23:18:45,English +アップデートで動作がさらに快適になりました。開発チームに感謝します。,play_store,2024-03-26 21:10:24,Japanese +Absolutely love this app! The interface is so intuitive and responsive.,play_store,2024-03-27 09:06:31,English +Très satisfait de la qualité du produit. Je le recommande vivement.,play_store,2024-03-27 09:40:27,French +Le temps de chargement est insupportable. Plusieurs minutes pour ouvrir une page.,web_form,2024-03-27 13:24:30,French +The onboarding process was seamless. I was up and running in minutes.,web_form,2024-03-27 20:56:13,English +Customer support responded within an hour and solved my issue on the first try.,email,2024-03-28 06:47:58,English +Bin absolut begeistert von der App. Läuft stabil und sieht super aus.,play_store,2024-03-28 07:56:00,German +このアプリは本当に使いやすくて、毎日愛用しています。おすすめです!,web_form,2024-03-30 10:30:39,Japanese +Works as expected. Would appreciate more customization options in future updates.,play_store,2024-03-31 21:29:36,English diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..b67666b68698ca491e5fa1c3c4c099279fdc3828 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json* ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD wget -qO- http://localhost:80/ || exit 1 diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..0570c1d126ff2b80564e6e197d8445ccf01c1392 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'coverage'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + }, + } +); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000000000000000000000000000000000000..126a49903a5063b99a653b4675209f1de1251d88 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Topic Analysis Dashboard + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000000000000000000000000000000000000..4ec4b73853f0d64280d14dc8cd2497bc3df6ee0a --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..e1464cda4537e240483b05bdf2d921d8985e81ac --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,7540 @@ +{ + "name": "topic-analysis-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "topic-analysis-frontend", + "version": "1.0.0", + "dependencies": { + "clsx": "2.1.1", + "d3-force": "3.0.0", + "d3-selection": "3.0.0", + "d3-zoom": "3.0.0", + "date-fns": "4.1.0", + "lucide-react": "0.468.0", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "7.1.1", + "recharts": "2.15.0" + }, + "devDependencies": { + "@eslint/js": "9.17.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.1.0", + "@testing-library/user-event": "14.5.2", + "@types/d3-force": "3.0.10", + "@types/d3-selection": "3.0.11", + "@types/d3-zoom": "3.0.8", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.3.4", + "@vitest/coverage-v8": "2.1.8", + "eslint": "9.17.0", + "eslint-plugin-react-hooks": "5.1.0", + "eslint-plugin-react-refresh": "0.4.16", + "globals": "15.14.0", + "jsdom": "25.0.1", + "msw": "2.7.0", + "prettier": "3.4.2", + "typescript": "5.7.2", + "typescript-eslint": "8.18.2", + "vite": "6.0.5", + "vitest": "2.1.8" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "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.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "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/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-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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "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.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "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/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bundled-es-modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cookie": "^0.7.2" + } + }, + "node_modules/@bundled-es-modules/statuses": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/statuses/-/statuses-1.0.1.tgz", + "integrity": "sha512-yn7BklA5acgcBr+7w064fGV+SGIFySjCKpqjcWgBAIfrAkY+4GQTJJHQMeT3V/sgz23VTEVV8TtOmkvJAhFVfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "statuses": "^2.0.1" + } + }, + "node_modules/@bundled-es-modules/tough-cookie": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@bundled-es-modules/tough-cookie/-/tough-cookie-0.1.6.tgz", + "integrity": "sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@types/tough-cookie": "^4.0.5", + "tough-cookie": "^4.1.4" + } + }, + "node_modules/@bundled-es-modules/tough-cookie/node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", + "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", + "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", + "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", + "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", + "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", + "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", + "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", + "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", + "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", + "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", + "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", + "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", + "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", + "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", + "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", + "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", + "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", + "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", + "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", + "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/eslint-utils/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-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/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "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/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, + "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/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.1.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.1.0.tgz", + "integrity": "sha512-Q2ToPvg0KsVL0ohND9A3zLJWcOXXcO8IDu3fj11KhNt0UlCWyFyvnCIBkd12tidB2lkiVRG8VFqdhcqhqnAQtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "license": "MIT" + }, + "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-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "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-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "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/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "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.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", + "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.5", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", + "integrity": "sha512-P4t6saawp+b/dFrUr2cvkVsfvPguwsxtH6dNIYRllMsefqFzkZk5UIjzyDOv5g1dXIPdG4Sp1yCR4Z6RCUsG/Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", + "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/type-utils": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "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.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", + "integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz", + "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", + "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", + "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz", + "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "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.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", + "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", + "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.8.tgz", + "integrity": "sha512-2Y7BPlKH18mAZYAW1tYByudlCYrQyl5RGvnnDYJKW5tCiO5qg3KSAy3XAxcxKz900a0ZXxWtKrMuZLe3lKBpJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.8", + "vitest": "2.1.8" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.8.tgz", + "integrity": "sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.8.tgz", + "integrity": "sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.8", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.8.tgz", + "integrity": "sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.8.tgz", + "integrity": "sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.8.tgz", + "integrity": "sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.8", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.8.tgz", + "integrity": "sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "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/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-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "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.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/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/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "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/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/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/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cssstyle/node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "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-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "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-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "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-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "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-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "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/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "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": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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/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/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "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/esbuild": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", + "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.0", + "@esbuild/android-arm": "0.24.0", + "@esbuild/android-arm64": "0.24.0", + "@esbuild/android-x64": "0.24.0", + "@esbuild/darwin-arm64": "0.24.0", + "@esbuild/darwin-x64": "0.24.0", + "@esbuild/freebsd-arm64": "0.24.0", + "@esbuild/freebsd-x64": "0.24.0", + "@esbuild/linux-arm": "0.24.0", + "@esbuild/linux-arm64": "0.24.0", + "@esbuild/linux-ia32": "0.24.0", + "@esbuild/linux-loong64": "0.24.0", + "@esbuild/linux-mips64el": "0.24.0", + "@esbuild/linux-ppc64": "0.24.0", + "@esbuild/linux-riscv64": "0.24.0", + "@esbuild/linux-s390x": "0.24.0", + "@esbuild/linux-x64": "0.24.0", + "@esbuild/netbsd-x64": "0.24.0", + "@esbuild/openbsd-arm64": "0.24.0", + "@esbuild/openbsd-x64": "0.24.0", + "@esbuild/sunos-x64": "0.24.0", + "@esbuild/win32-arm64": "0.24.0", + "@esbuild/win32-ia32": "0.24.0", + "@esbuild/win32-x64": "0.24.0" + } + }, + "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": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "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 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.16.tgz", + "integrity": "sha512-slterMlxAhov/DZO8NScf6mEeMBBXodFUolijDvrtTxyezyLoTQaa73FyYus/VbTdftd8wBgBxPMRk3poleXNQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.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/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "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": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.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": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", + "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "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/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-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "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": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "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": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/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/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "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/graphql": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.1.tgz", + "integrity": "sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "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-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.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-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-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, + "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-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "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/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "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/jsdom": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", + "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.1.0", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.12", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.7.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "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/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/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.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "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/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "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.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "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/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/msw": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.7.0.tgz", + "integrity": "sha512-BIodwZ19RWfCbYTxWTUfTXc+sg4OwjCAgxU1ZsgmggX/7S3LdUifsbUPJs61j0rWb19CZRGY5if77duhc0uXzw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@bundled-es-modules/cookie": "^2.0.1", + "@bundled-es-modules/statuses": "^1.0.1", + "@bundled-es-modules/tough-cookie": "^0.1.6", + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.37.0", + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/until": "^2.1.0", + "@types/cookie": "^0.6.0", + "@types/statuses": "^2.0.4", + "graphql": "^16.8.1", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "strict-event-emitter": "^0.5.1", + "type-fest": "^4.26.1", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "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/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/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, + "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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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-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-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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/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/prettier": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", + "integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/prop-types/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/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "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/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, + "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-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": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.1.tgz", + "integrity": "sha512-39sXJkftkKWRZ2oJtHhCxmoCrBCULr/HAH4IT5DHlgu/Q0FCPV0S4Lx+abjDTx/74xoZzNYDYbOZWlJjruyuDQ==", + "license": "MIT", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.1.tgz", + "integrity": "sha512-vSrQHWlJ5DCfyrhgo0k6zViOe9ToK8uT5XGSmnuC2R3/g261IdIMpZVqfjD6vWSXdnf5Czs4VA/V60oVR6/jnA==", + "license": "MIT", + "dependencies": { + "react-router": "7.1.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-router/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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-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/recharts": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", + "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.0", + "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/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, + "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/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz", + "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", + "dev": true, + "license": "MIT" + }, + "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/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "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": "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "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/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/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-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "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-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "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/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/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "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/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "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/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", + "license": "ISC" + }, + "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": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.2.tgz", + "integrity": "sha512-KuXezG6jHkvC3MvizeXgupZzaG5wjhU3yE8E7e6viOvAvD9xAWYp8/vy0WULTGe9DYDWcQu7aW03YIV3mSitrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.18.2", + "@typescript-eslint/parser": "8.18.2", + "@typescript-eslint/utils": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "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/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "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": "6.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz", + "integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.24.0", + "postcss": "^8.4.49", + "rollup": "^4.23.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.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 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.8.tgz", + "integrity": "sha512-uPAwSr57kYjAUux+8E2j0q0Fxpn8M9VoyfGiRI8Kfktz9NcYMCenwY5RnZxnF1WTu3TGiYipirIzacLL3VVGFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vite-node/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/vitest": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.8.tgz", + "integrity": "sha512-1vBKTZskHw/aosXqQUlVWWlGUxSJR8YtiyZDJAFeW2kPAeX6S3Sool0mjspO+kXLuxVWlEDDowBAeqeAQefqLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.8", + "@vitest/mocker": "2.1.8", + "@vitest/pretty-format": "^2.1.8", + "@vitest/runner": "2.1.8", + "@vitest/snapshot": "2.1.8", + "@vitest/spy": "2.1.8", + "@vitest/utils": "2.1.8", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.8", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.8", + "@vitest/ui": "2.1.8", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/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/vitest/node_modules/@vitest/mocker": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.8.tgz", + "integrity": "sha512-7guJ/47I6uqfttp33mgo6ga5Gr1VnL58rcqYKyShoRK9ebu8T5Rs6HN3s1NABiBeVTdWNrwUMcHH54uXZBN4zA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/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/vitest/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/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "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/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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/wrap-ansi/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/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/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "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/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/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/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" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000000000000000000000000000000000000..cbf7779d847ce7b4941c95f81d228da44629a8f6 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,51 @@ +{ + "name": "topic-analysis-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "format": "prettier --write 'src/**/*.{ts,tsx,css}'", + "preview": "vite preview", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "7.1.1", + "recharts": "2.15.0", + "d3-force": "3.0.0", + "d3-selection": "3.0.0", + "d3-zoom": "3.0.0", + "lucide-react": "0.468.0", + "clsx": "2.1.1", + "date-fns": "4.1.0" + }, + "devDependencies": { + "@eslint/js": "9.17.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.1.0", + "@testing-library/user-event": "14.5.2", + "@types/d3-force": "3.0.10", + "@types/d3-selection": "3.0.11", + "@types/d3-zoom": "3.0.8", + "@types/react": "18.3.18", + "@types/react-dom": "18.3.5", + "@vitejs/plugin-react": "4.3.4", + "eslint": "9.17.0", + "eslint-plugin-react-hooks": "5.1.0", + "eslint-plugin-react-refresh": "0.4.16", + "globals": "15.14.0", + "jsdom": "25.0.1", + "msw": "2.7.0", + "prettier": "3.4.2", + "typescript": "5.7.2", + "typescript-eslint": "8.18.2", + "vite": "6.0.5", + "vitest": "2.1.8", + "@vitest/coverage-v8": "2.1.8" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f7417d12e5e568fceac133fb3222d1409f6de7fb --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,33 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { Sidebar } from './components/layout/Sidebar'; +import { DashboardPage } from './pages/DashboardPage'; +import { UploadPage } from './pages/UploadPage'; +import { DataQualityPage } from './pages/DataQualityPage'; +import { ComparePage } from './pages/ComparePage'; +import { SettingsPage } from './pages/SettingsPage'; +import { AnalysisProvider } from './hooks/useAnalysis'; +import { useTheme } from './hooks/useTheme'; +import './styles/globals.css'; + +export default function App() { + const { theme, toggleTheme } = useTheme(); + + return ( + + +
+ +
+ + } /> + } /> + } /> + } /> + } /> + +
+
+
+
+ ); +} diff --git a/frontend/src/__mocks__/handlers.ts b/frontend/src/__mocks__/handlers.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a436d36490ca162d4539fbbe436796cc348159a --- /dev/null +++ b/frontend/src/__mocks__/handlers.ts @@ -0,0 +1,185 @@ +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; +import type { AnalysisResult, JobStatus } from '../types'; + +const mockJobStatus: JobStatus = { + job_id: 'test-job-1', + status: 'completed', + progress: 1.0, + message: 'Analysis complete', + created_at: '2024-01-01T00:00:00Z', + completed_at: '2024-01-01T00:05:00Z', +}; + +const mockAnalysisResult: AnalysisResult = { + job_id: 'test-job-1', + status: 'completed', + created_at: '2024-01-01T00:00:00Z', + completed_at: '2024-01-01T00:05:00Z', + total_entries: 3, + entries: [ + { + id: '1', + text: 'Great product!', + source: 'survey', + timestamp: '2024-01-01T00:00:00Z', + sentiment: { label: 'positive', score: 0.9, confidence: 0.95 }, + language: { language: 'en', confidence: 0.99, method: 'langdetect' }, + topic_id: 0, + topic_label: 'Product Quality', + }, + { + id: '2', + text: 'Terrible service', + source: 'email', + timestamp: '2024-01-02T00:00:00Z', + sentiment: { label: 'negative', score: 0.2, confidence: 0.88 }, + language: { language: 'en', confidence: 0.98, method: 'langdetect' }, + topic_id: 1, + topic_label: 'Customer Service', + }, + { + id: '3', + text: 'It works fine', + source: 'chat', + timestamp: '2024-01-03T00:00:00Z', + sentiment: { label: 'neutral', score: 0.5, confidence: 0.7 }, + language: { language: 'en', confidence: 0.95, method: 'langdetect' }, + topic_id: 0, + topic_label: 'Product Quality', + }, + ], + topics: [ + { + topic_id: 0, + label: 'Product Quality', + keywords: ['product', 'quality', 'great'], + size: 2, + avg_sentiment: 0.7, + sentiment_distribution: { positive: 1, neutral: 1, negative: 0 }, + languages: { en: 2 }, + representative_docs: ['Great product!', 'It works fine'], + }, + { + topic_id: 1, + label: 'Customer Service', + keywords: ['service', 'support', 'help'], + size: 1, + avg_sentiment: 0.2, + sentiment_distribution: { positive: 0, neutral: 0, negative: 1 }, + languages: { en: 1 }, + representative_docs: ['Terrible service'], + }, + ], + sentiment_trends: [ + { + period: '2024-01-01', + avg_sentiment: 0.9, + count: 1, + positive: 1, + negative: 0, + neutral: 0, + confidence_lower: 0.8, + confidence_upper: 1.0, + }, + { + period: '2024-01-02', + avg_sentiment: 0.2, + count: 1, + positive: 0, + negative: 1, + neutral: 0, + confidence_lower: 0.1, + confidence_upper: 0.3, + }, + ], + topic_graph: { + nodes: [ + { + topic_id: 0, + label: 'Product Quality', + keywords: ['product'], + size: 2, + avg_sentiment: 0.7, + sentiment_distribution: { positive: 1, neutral: 1 }, + languages: { en: 2 }, + representative_docs: [], + }, + ], + links: [], + }, + data_quality: { + total_entries: 3, + low_confidence_count: 0, + low_confidence_entries: [], + mixed_language_count: 0, + mixed_language_entries: [], + duplicate_count: 0, + duplicate_entries: [], + avg_confidence: 0.843, + language_distribution: { en: 3 }, + }, + anomalies: [], + summary: { + total_entries: 3, + avg_sentiment: 0.533, + dominant_sentiment: 'positive', + num_topics: 2, + top_topics: [ + { topic_id: 0, label: 'Product Quality', keywords: ['product'], size: 2 }, + { topic_id: 1, label: 'Customer Service', keywords: ['service'], size: 1 }, + ], + languages_detected: ['en'], + date_range: { start: '2024-01-01', end: '2024-01-03' }, + }, +}; + +export const handlers = [ + http.get('/api/v1/jobs', () => { + return HttpResponse.json([mockJobStatus]); + }), + + http.get('/api/v1/jobs/:jobId', () => { + return HttpResponse.json(mockAnalysisResult); + }), + + http.get('/api/v1/jobs/:jobId/status', () => { + return HttpResponse.json(mockJobStatus); + }), + + http.post('/api/v1/upload', () => { + return HttpResponse.json(mockJobStatus); + }), + + http.post('/api/v1/jobs/:jobId/filter', () => { + return HttpResponse.json({ + total: 3, + page: 1, + entries: mockAnalysisResult.entries, + }); + }), + + http.post('/api/v1/jobs/:jobId/compare', () => { + return HttpResponse.json({ + segment_a: mockAnalysisResult.summary, + segment_b: mockAnalysisResult.summary, + sentiment_delta: 0.1, + topic_changes: [], + new_topics: [], + disappeared_topics: [], + }); + }), + + http.get('/health', () => { + return HttpResponse.json({ + status: 'healthy', + version: '1.0.0', + models_loaded: true, + redis_connected: true, + uptime_seconds: 100, + }); + }), +]; + +export const server = setupServer(...handlers); +export { mockAnalysisResult, mockJobStatus }; diff --git a/frontend/src/__tests__/components.test.tsx b/frontend/src/__tests__/components.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb50f9d8bab38f6512811893a3dcb0e12ae42729 --- /dev/null +++ b/frontend/src/__tests__/components.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import { server } from '../__mocks__/handlers'; +import { Sidebar } from '../components/layout/Sidebar'; +import { Alert } from '../components/common/Alert'; +import { Skeleton } from '../components/common/Skeleton'; +import { DataQualityPanel } from '../components/quality/DataQualityPanel'; +import type { DataQualityReport } from '../types'; + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe('Sidebar', () => { + it('renders navigation items', () => { + render( + + {}} /> + , + ); + + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + expect(screen.getByText('Upload Data')).toBeInTheDocument(); + expect(screen.getByText('Data Quality')).toBeInTheDocument(); + expect(screen.getByText('Compare')).toBeInTheDocument(); + }); + + it('renders theme toggle button', () => { + render( + + {}} /> + , + ); + + expect(screen.getByText('Light Mode')).toBeInTheDocument(); + }); + + it('shows dark mode text when theme is light', () => { + render( + + {}} /> + , + ); + + expect(screen.getByText('Dark Mode')).toBeInTheDocument(); + }); +}); + +describe('Alert', () => { + it('renders danger alert', () => { + render(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('renders success alert', () => { + render(); + expect(screen.getByText('Operation completed')).toBeInTheDocument(); + }); + + it('calls onDismiss when close button clicked', async () => { + const onDismiss = vi.fn(); + render(); + + const dismissBtn = screen.getByLabelText('Dismiss'); + dismissBtn.click(); + expect(onDismiss).toHaveBeenCalledOnce(); + }); +}); + +describe('Skeleton', () => { + it('renders loading skeleton', () => { + render(); + const skeletons = screen.getAllByRole('status'); + expect(skeletons).toHaveLength(3); + }); +}); + +describe('DataQualityPanel', () => { + const mockReport: DataQualityReport = { + total_entries: 100, + low_confidence_count: 5, + low_confidence_entries: ['1', '2', '3', '4', '5'], + mixed_language_count: 3, + mixed_language_entries: ['6', '7', '8'], + duplicate_count: 2, + duplicate_entries: ['9', '10'], + avg_confidence: 0.85, + language_distribution: { en: 80, es: 12, fr: 8 }, + }; + + it('renders quality stats', () => { + render(); + + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('shows health score', () => { + render(); + expect(screen.getByText('Data Health Score')).toBeInTheDocument(); + }); + + it('displays language distribution', () => { + render(); + expect(screen.getByText('en: 80')).toBeInTheDocument(); + expect(screen.getByText('es: 12')).toBeInTheDocument(); + }); + + it('shows warning when issues exist', () => { + render(); + expect(screen.getByText(/data quality issue/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/charts/SentimentChart.tsx b/frontend/src/components/charts/SentimentChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3e2b2f2bcee138dc44ea4f3a755db9c926b8094f --- /dev/null +++ b/frontend/src/components/charts/SentimentChart.tsx @@ -0,0 +1,129 @@ +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Area, + AreaChart, + Legend, +} from 'recharts'; +import type { SentimentTrend } from '../../types'; + +interface SentimentChartProps { + data: SentimentTrend[]; + height?: number; +} + +export function SentimentTrendChart({ data, height = 300 }: SentimentChartProps) { + if (!data.length) { + return ( +
+ No trend data available +
+ ); + } + + return ( + + + + + + + + + + + + + + + + [value.toFixed(3), '']} + /> + + + + + + + ); +} + +interface SentimentDistributionProps { + positive: number; + negative: number; + neutral: number; +} + +export function SentimentDistribution({ positive, negative, neutral }: SentimentDistributionProps) { + const total = positive + negative + neutral || 1; + + return ( +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/charts/TopicChart.tsx b/frontend/src/components/charts/TopicChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d66f18f13af9f7b4c03daefa633d4eb56cccea9b --- /dev/null +++ b/frontend/src/components/charts/TopicChart.tsx @@ -0,0 +1,75 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; +import type { TopicCluster } from '../../types'; + +interface TopicBarChartProps { + topics: TopicCluster[]; + height?: number; +} + +const COLORS = [ + '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899', + '#f43f5e', '#f97316', '#eab308', '#22c55e', '#14b8a6', + '#06b6d4', '#3b82f6', +]; + +export function TopicBarChart({ topics, height = 300 }: TopicBarChartProps) { + const data = topics + .filter((t) => t.topic_id !== -1) + .sort((a, b) => b.size - a.size) + .slice(0, 15) + .map((t) => ({ + name: t.label.length > 25 ? t.label.slice(0, 25) + '…' : t.label, + size: t.size, + sentiment: t.avg_sentiment, + topic_id: t.topic_id, + })); + + if (!data.length) { + return ( +
+ No topics found +
+ ); + } + + return ( + + + + + + { + if (name === 'size') return [value, 'Entries']; + return [value.toFixed(3), 'Avg Sentiment']; + }} + /> + + {data.map((_, index) => ( + + ))} + + + + ); +} diff --git a/frontend/src/components/common/Alert.tsx b/frontend/src/components/common/Alert.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8dc39d95b0c75c06732da25a901f37b81943dbca --- /dev/null +++ b/frontend/src/components/common/Alert.tsx @@ -0,0 +1,35 @@ +import { AlertCircle, CheckCircle, AlertTriangle, Info } from 'lucide-react'; + +interface AlertProps { + type: 'success' | 'danger' | 'warning' | 'info'; + message: string; + onDismiss?: () => void; +} + +const icons = { + success: CheckCircle, + danger: AlertCircle, + warning: AlertTriangle, + info: Info, +}; + +export function Alert({ type, message, onDismiss }: AlertProps) { + const Icon = icons[type]; + + return ( +
+ + {message} + {onDismiss && ( + + )} +
+ ); +} diff --git a/frontend/src/components/common/Skeleton.tsx b/frontend/src/components/common/Skeleton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2331a84c7d3e1ea0164a489f8a544217c69dcab6 --- /dev/null +++ b/frontend/src/components/common/Skeleton.tsx @@ -0,0 +1,45 @@ +interface SkeletonProps { + variant?: 'text' | 'heading' | 'chart' | 'card'; + width?: string; + height?: string; + count?: number; +} + +export function Skeleton({ variant = 'text', width, height, count = 1 }: SkeletonProps) { + const className = `skeleton skeleton-${variant}`; + const style = { width, height }; + + return ( + <> + {Array.from({ length: count }, (_, i) => ( +
+ ))} + + ); +} + +export function CardSkeleton() { + return ( +
+
+ +
+
+ +
+
+ ); +} + +export function StatsSkeleton() { + return ( +
+ {Array.from({ length: 4 }, (_, i) => ( +
+ + +
+ ))} +
+ ); +} diff --git a/frontend/src/components/filters/FilterBar.tsx b/frontend/src/components/filters/FilterBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..76fad55fe468b8449b22732518e30f022fe08de8 --- /dev/null +++ b/frontend/src/components/filters/FilterBar.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import type { FilterParams } from '../../types'; + +interface FilterBarProps { + topics: number[]; + languages: string[]; + sources: string[]; + onFilter: (filters: FilterParams) => void; + onReset: () => void; +} + +export function FilterBar({ topics, languages, sources, onFilter, onReset }: FilterBarProps) { + const [filters, setFilters] = useState({}); + + const handleChange = (key: keyof FilterParams, value: unknown) => { + const updated = { ...filters, [key]: value || undefined }; + setFilters(updated); + }; + + const handleApply = () => { + onFilter(filters); + }; + + const handleReset = () => { + setFilters({}); + onReset(); + }; + + return ( +
+
+ + handleChange('date_from', e.target.value ? e.target.value + 'T00:00:00' : undefined)} + /> +
+ +
+ + handleChange('date_to', e.target.value ? e.target.value + 'T23:59:59' : undefined)} + /> +
+ +
+ +
+ handleChange('sentiment_min', e.target.value ? Number(e.target.value) : undefined)} + /> + handleChange('sentiment_max', e.target.value ? Number(e.target.value) : undefined)} + /> +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + handleChange('search_text', e.target.value || undefined)} + style={{ width: 160 }} + /> +
+ +
+ + +
+
+ ); +} diff --git a/frontend/src/components/graphs/ForceGraph.tsx b/frontend/src/components/graphs/ForceGraph.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1be2d046d47571b9f43ca00ce85b19fe522e8104 --- /dev/null +++ b/frontend/src/components/graphs/ForceGraph.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from 'react'; +import type { TopicGraph, TopicCluster } from '../../types'; + +interface ForceGraphProps { + graph: TopicGraph; + width?: number; + height?: number; + onNodeClick?: (topic: TopicCluster) => void; +} + +interface SimNode extends TopicCluster { + x: number; + y: number; + vx: number; + vy: number; +} + +interface SimLink { + source: SimNode; + target: SimNode; + weight: number; +} + +export function ForceGraph({ graph, width = 600, height = 400, onNodeClick }: ForceGraphProps) { + const svgRef = useRef(null); + const [nodes, setNodes] = useState([]); + const [links, setLinks] = useState([]); + const [hoveredNode, setHoveredNode] = useState(null); + const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 }); + const animRef = useRef(0); + + useEffect(() => { + if (!graph.nodes.length) return; + + const simNodes: SimNode[] = graph.nodes + .filter((n) => n.topic_id !== -1) + .map((n) => ({ + ...n, + x: width / 2 + (Math.random() - 0.5) * 200, + y: height / 2 + (Math.random() - 0.5) * 200, + vx: 0, + vy: 0, + })); + + const nodeMap = new Map(simNodes.map((n) => [n.topic_id, n])); + const simLinks: SimLink[] = graph.links + .filter((l) => nodeMap.has(l.source) && nodeMap.has(l.target)) + .map((l) => ({ + source: nodeMap.get(l.source)!, + target: nodeMap.get(l.target)!, + weight: l.weight, + })); + + // Simple force simulation + const alpha = { value: 1 }; + const centerX = width / 2; + const centerY = height / 2; + + function tick() { + if (alpha.value < 0.001) return; + alpha.value *= 0.99; + + // Repulsion + for (let i = 0; i < simNodes.length; i++) { + for (let j = i + 1; j < simNodes.length; j++) { + const a = simNodes[i]!; + const b = simNodes[j]!; + const dx = b.x - a.x; + const dy = b.y - a.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (500 * alpha.value) / (dist * dist); + a.vx -= (dx / dist) * force; + a.vy -= (dy / dist) * force; + b.vx += (dx / dist) * force; + b.vy += (dy / dist) * force; + } + } + + // Attraction (links) + for (const link of simLinks) { + const dx = link.target.x - link.source.x; + const dy = link.target.y - link.source.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const force = (dist - 100) * 0.01 * alpha.value * link.weight; + link.source.vx += (dx / dist) * force; + link.source.vy += (dy / dist) * force; + link.target.vx -= (dx / dist) * force; + link.target.vy -= (dy / dist) * force; + } + + // Center gravity + for (const node of simNodes) { + node.vx += (centerX - node.x) * 0.01 * alpha.value; + node.vy += (centerY - node.y) * 0.01 * alpha.value; + node.vx *= 0.9; + node.vy *= 0.9; + node.x += node.vx; + node.y += node.vy; + } + + setNodes([...simNodes]); + setLinks([...simLinks]); + animRef.current = requestAnimationFrame(tick); + } + + animRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(animRef.current); + }, [graph, width, height]); + + const handleWheel = (e: React.WheelEvent) => { + e.preventDefault(); + const scaleFactor = e.deltaY > 0 ? 0.9 : 1.1; + setTransform((prev) => ({ + ...prev, + k: Math.max(0.2, Math.min(3, prev.k * scaleFactor)), + })); + }; + + const getNodeRadius = (size: number) => Math.max(8, Math.min(30, Math.sqrt(size) * 3)); + + const getSentimentColor = (sentiment: number) => { + if (sentiment > 0.6) return 'var(--success)'; + if (sentiment < 0.4) return 'var(--danger)'; + return 'var(--warning)'; + }; + + if (!graph.nodes.length || nodes.length === 0) { + return ( +
+ No topic graph data available +
+ ); + } + + return ( + + + {/* Links */} + {links.map((link, i) => ( + + ))} + + {/* Nodes */} + {nodes.map((node) => { + const r = getNodeRadius(node.size); + const isHovered = hoveredNode === node.topic_id; + + return ( + + setHoveredNode(node.topic_id)} + onMouseLeave={() => setHoveredNode(null)} + onClick={() => onNodeClick?.(node)} + style={{ cursor: 'pointer', transition: 'fill-opacity 0.2s' }} + role="button" + tabIndex={0} + aria-label={`Topic: ${node.label}, Size: ${node.size}`} + /> + {(isHovered || node.size > 20) && ( + + {node.label.length > 20 ? node.label.slice(0, 20) + '…' : node.label} + + )} + {isHovered && ( + + {node.size} entries • sentiment: {node.avg_sentiment.toFixed(2)} + + )} + + ); + })} + + + ); +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2fa39c626117aca6d1a2965a486ba46d8169fe35 --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,63 @@ +import { NavLink } from 'react-router-dom'; +import { + LayoutDashboard, + Upload, + ShieldCheck, + GitCompareArrows, + Sun, + Moon, + Settings, +} from 'lucide-react'; +import type { Theme } from '../../types'; + +interface SidebarProps { + theme: Theme; + onToggleTheme: () => void; +} + +const navItems = [ + { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, + { to: '/upload', icon: Upload, label: 'Upload Data' }, + { to: '/quality', icon: ShieldCheck, label: 'Data Quality' }, + { to: '/compare', icon: GitCompareArrows, label: 'Compare' }, +]; + +export function Sidebar({ theme, onToggleTheme }: SidebarProps) { + return ( + + ); +} diff --git a/frontend/src/components/quality/DataQualityPanel.tsx b/frontend/src/components/quality/DataQualityPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ddac967740ac0eaac196608824c71289b4c9228 --- /dev/null +++ b/frontend/src/components/quality/DataQualityPanel.tsx @@ -0,0 +1,230 @@ +import { useState } from 'react'; +import type { AnalyzedEntry, DataQualityReport } from '../../types'; +import { AlertTriangle, Copy, Globe, TrendingDown, ChevronDown, ChevronUp } from 'lucide-react'; + +interface DataQualityPanelProps { + report: DataQualityReport; + entries?: AnalyzedEntry[]; +} + +type DrillDownType = 'low_confidence' | 'mixed_language' | 'duplicates' | null; + +export function DataQualityPanel({ report, entries = [] }: DataQualityPanelProps) { + const [drillDown, setDrillDown] = useState(null); + const issueCount = report.low_confidence_count + report.mixed_language_count + report.duplicate_count; + const healthScore = Math.max(0, 100 - (issueCount / Math.max(1, report.total_entries)) * 100); + + const toggleDrillDown = (type: DrillDownType) => { + setDrillDown(drillDown === type ? null : type); + }; + + const getDrillDownEntries = (): AnalyzedEntry[] => { + if (!drillDown || entries.length === 0) return []; + const idSet = new Set( + drillDown === 'low_confidence' ? report.low_confidence_entries + : drillDown === 'mixed_language' ? report.mixed_language_entries + : report.duplicate_entries + ); + return entries.filter((e) => idSet.has(e.id)); + }; + + const drillDownEntries = getDrillDownEntries(); + + return ( +
+
+
toggleDrillDown('low_confidence')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && toggleDrillDown('low_confidence')} + > +
+ + Low Confidence +
+
{report.low_confidence_count}
+
+ + predictions below 50% confidence + + {report.low_confidence_count > 0 && ( + drillDown === 'low_confidence' + ? + : + )} +
+
+ +
toggleDrillDown('mixed_language')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && toggleDrillDown('mixed_language')} + > +
+ + Mixed Language +
+
{report.mixed_language_count}
+
+ + entries differ from majority language + + {report.mixed_language_count > 0 && ( + drillDown === 'mixed_language' + ? + : + )} +
+
+ +
toggleDrillDown('duplicates')} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && toggleDrillDown('duplicates')} + > +
+ + Duplicates +
+
{report.duplicate_count}
+
+ + exact or near-duplicate entries + + {report.duplicate_count > 0 && ( + drillDown === 'duplicates' + ? + : + )} +
+
+
+ + {/* Drill-down entries table */} + {drillDown && ( +
+
+

+ {drillDown === 'low_confidence' ? 'Low Confidence Entries' : + drillDown === 'mixed_language' ? 'Mixed Language Entries' : + 'Duplicate Entries'} +

+ + {drillDownEntries.length > 0 + ? `${drillDownEntries.length} entries` + : `${drillDown === 'low_confidence' ? report.low_confidence_count : drillDown === 'mixed_language' ? report.mixed_language_count : report.duplicate_count} entry IDs found`} + +
+
+ {drillDownEntries.length > 0 ? ( + + + + + + + + {drillDown === 'low_confidence' && } + + + + {drillDownEntries.slice(0, 50).map((entry) => ( + + + + + + {drillDown === 'low_confidence' && ( + + )} + + ))} + +
TextSentimentConfidenceLanguageScore
+ {entry.text} + + {entry.sentiment.label} + + + {(entry.sentiment.confidence * 100).toFixed(1)}% + + + {entry.language.language} + {entry.sentiment.score.toFixed(3)}
+ ) : ( +
+

Entry details not available. IDs:

+
+ {(drillDown === 'low_confidence' ? report.low_confidence_entries : + drillDown === 'mixed_language' ? report.mixed_language_entries : + report.duplicate_entries).slice(0, 20).map((id) => ( + + {id} + + ))} +
+
+ )} +
+
+ )} + +
+
+

Data Health Score

+ 80 ? 'badge-positive' : healthScore > 50 ? 'badge-warning' : 'badge-negative'}`}> + {healthScore.toFixed(0)}% + +
+
+
+
80 ? 'var(--success)' : healthScore > 50 ? 'var(--warning)' : 'var(--danger)', + }} + /> +
+ +
+
Language Distribution
+
+ {Object.entries(report.language_distribution).map(([lang, count]) => ( + + {lang}: {count} + + ))} +
+
+ +
+
Average Confidence
+
+ {(report.avg_confidence * 100).toFixed(1)}% +
+
+
+
+ + {issueCount > 0 && ( +
+ + + {issueCount} data quality issue{issueCount !== 1 ? 's' : ''} detected. + Click the cards above to review affected entries. + +
+ )} +
+ ); +} diff --git a/frontend/src/components/upload/FileUpload.tsx b/frontend/src/components/upload/FileUpload.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09e2b365aabeb0be4b9eeae572e7e2cd84824f47 --- /dev/null +++ b/frontend/src/components/upload/FileUpload.tsx @@ -0,0 +1,149 @@ +import { useState, useCallback, useRef } from 'react'; +import { Upload, FileText, X } from 'lucide-react'; + +interface FileUploadProps { + onUpload: (file: File, source?: string) => Promise; + loading?: boolean; +} + +const ACCEPTED_TYPES = [ + '.csv', + '.json', + '.xlsx', + '.xls', + '.zip', + 'text/csv', + 'application/json', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-excel', + 'application/zip', +]; + +export function FileUpload({ onUpload, loading }: FileUploadProps) { + const [dragOver, setDragOver] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [source, setSource] = useState(''); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const validateFile = (file: File): boolean => { + const ext = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!['.csv', '.json', '.xlsx', '.xls', '.zip'].includes(ext)) { + setError(`Unsupported format: ${ext}. Use CSV, JSON, Excel, or ZIP.`); + return false; + } + if (file.size > 500 * 1024 * 1024) { + setError('File too large. Maximum 500MB.'); + return false; + } + setError(null); + return true; + }; + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file && validateFile(file)) { + setSelectedFile(file); + } + }, []); + + const handleFileSelect = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file && validateFile(file)) { + setSelectedFile(file); + } + }, []); + + const handleSubmit = async () => { + if (!selectedFile) return; + try { + await onUpload(selectedFile, source || undefined); + setSelectedFile(null); + setSource(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Upload failed'); + } + }; + + const formatSize = (bytes: number) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + + return ( +
+
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => inputRef.current?.click()} + role="button" + tabIndex={0} + aria-label="Upload file" + onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()} + > + +

Drop files here or click to browse

+

Supports CSV, JSON, Excel (.xlsx/.xls), and ZIP files up to 500MB

+

+ Files >10MB will be uploaded in chunks automatically +

+ +
+ + {error && ( +
+ {error} + +
+ )} + + {selectedFile && ( +
+
+
+ +
+
{selectedFile.name}
+
{formatSize(selectedFile.size)}
+
+
+ +
+
+
+ + setSource(e.target.value)} + /> +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/hooks/useAnalysis.tsx b/frontend/src/hooks/useAnalysis.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67386b5fe8c481bed8cfc8eece59da6b4fa5c373 --- /dev/null +++ b/frontend/src/hooks/useAnalysis.tsx @@ -0,0 +1,144 @@ +import { useState, useCallback, useContext, createContext, type ReactNode } from 'react'; +import type { AnalysisResult, FilterParams, JobStatus } from '../types'; +import { api } from '../services/api'; + +interface AnalysisState { + jobs: JobStatus[]; + currentResult: AnalysisResult | null; + activeJobId: string | null; + loading: boolean; + error: string | null; + uploadFile: (file: File, source?: string) => Promise; + loadJobs: () => Promise; + loadResult: (jobId: string) => Promise; + selectJob: (jobId: string) => void; + pollJobStatus: (jobId: string, onUpdate?: (status: JobStatus) => void) => Promise; + exportResults: (jobId: string, format: 'csv' | 'json' | 'pdf', filters?: FilterParams) => Promise; + setError: (error: string | null) => void; +} + +const AnalysisContext = createContext(null); + +export function AnalysisProvider({ children }: { children: ReactNode }) { + const [jobs, setJobs] = useState([]); + const [currentResult, setCurrentResult] = useState(null); + const [activeJobId, setActiveJobId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const uploadFile = useCallback(async (file: File, source?: string) => { + setLoading(true); + setError(null); + try { + const useChunked = file.size > 10 * 1024 * 1024; + const status = useChunked ? await api.uploadChunked(file) : await api.uploadFile(file, source); + setJobs((prev) => [status, ...prev]); + return status; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Upload failed'; + setError(msg); + throw err; + } finally { + setLoading(false); + } + }, []); + + const loadJobs = useCallback(async () => { + try { + const data = await api.getJobs(); + setJobs(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load jobs'); + } + }, []); + + const loadResult = useCallback(async (jobId: string) => { + setLoading(true); + setError(null); + try { + const result = await api.getJobResult(jobId); + setCurrentResult(result); + setActiveJobId(jobId); + return result; + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load results'); + throw err; + } finally { + setLoading(false); + } + }, []); + + const selectJob = useCallback((jobId: string) => { + setActiveJobId(jobId); + loadResult(jobId); + }, [loadResult]); + + const pollJobStatus = useCallback( + async (jobId: string, onUpdate?: (status: JobStatus) => void) => { + const poll = async () => { + try { + const status = await api.getJobStatus(jobId); + onUpdate?.(status); + if (status.status === 'completed') { + await loadResult(jobId); + return; + } + if (status.status === 'failed') { + setError('Analysis failed'); + return; + } + setTimeout(poll, 2000); + } catch { + setTimeout(poll, 5000); + } + }; + poll(); + }, + [loadResult], + ); + + const exportResults = useCallback(async (jobId: string, format: 'csv' | 'json' | 'pdf', filters?: FilterParams) => { + try { + const blob = await api.exportResults(jobId, format, filters); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `analysis_${jobId}.${format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : 'Export failed'); + } + }, []); + + return ( + + {children} + + ); +} + +export function useAnalysis(): AnalysisState { + const context = useContext(AnalysisContext); + if (!context) { + throw new Error('useAnalysis must be used within an AnalysisProvider'); + } + return context; +} diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..730759e6c17572dda6d75156f669ff4c17be4573 --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,21 @@ +import { useState, useEffect, useCallback } from 'react'; +import type { Theme } from '../types'; + +export function useTheme() { + const [theme, setTheme] = useState(() => { + const stored = localStorage.getItem('theme') as Theme | null; + if (stored) return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + }); + + useEffect(() => { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }, [theme]); + + const toggleTheme = useCallback(() => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }, []); + + return { theme, toggleTheme }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d9736adc9cd594faf47c68b7773676dded0ebb76 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/frontend/src/pages/ComparePage.tsx b/frontend/src/pages/ComparePage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8926cdd010d0b5d21c257480696bc1dc56dec839 --- /dev/null +++ b/frontend/src/pages/ComparePage.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from 'react'; +import { useAnalysis } from '../hooks/useAnalysis'; +import { Alert } from '../components/common/Alert'; +import { CardSkeleton } from '../components/common/Skeleton'; +import type { ComparisonResult, FilterParams } from '../types'; +import { api } from '../services/api'; +import { ArrowRight, TrendingUp, TrendingDown, Minus } from 'lucide-react'; + +export function ComparePage() { + const { jobs, currentResult, activeJobId, loadJobs, selectJob } = useAnalysis(); + const [comparison, setComparison] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [segmentA, setSegmentA] = useState({}); + const [segmentB, setSegmentB] = useState({}); + + useEffect(() => { + loadJobs(); + }, [loadJobs]); + + useEffect(() => { + if (jobs.length > 0 && !activeJobId) { + const completed = jobs.find((j) => j.status === 'completed'); + if (completed) selectJob(completed.job_id); + } + }, [jobs, activeJobId, selectJob]); + + const handleCompare = async () => { + if (!currentResult) return; + setLoading(true); + setError(null); + try { + const result = await api.compareSegments(currentResult.job_id, segmentA, segmentB); + setComparison(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Comparison failed'); + } finally { + setLoading(false); + } + }; + + if (!currentResult) { + return ( +
+
+

Compare Segments

+

Upload and analyze data first to compare segments.

+
+
+ ); + } + + const DeltaIcon = comparison + ? comparison.sentiment_delta > 0.01 + ? TrendingUp + : comparison.sentiment_delta < -0.01 + ? TrendingDown + : Minus + : Minus; + + return ( +
+
+
+

Compare Segments

+

Compare sentiment and topics between two time periods or data segments

+
+ {jobs.filter((j) => j.status === 'completed').length > 1 && ( + + )} +
+ + {error && setError(null)} />} + +
+
+
+

Segment A

+
+
+
+ + setSegmentA({ ...segmentA, date_from: e.target.value + 'T00:00:00' })} + /> +
+
+ + setSegmentA({ ...segmentA, date_to: e.target.value + 'T23:59:59' })} + /> +
+
+
+ +
+
+

Segment B

+
+
+
+ + setSegmentB({ ...segmentB, date_from: e.target.value + 'T00:00:00' })} + /> +
+
+ + setSegmentB({ ...segmentB, date_to: e.target.value + 'T23:59:59' })} + /> +
+
+
+
+ + + + {loading && } + + {comparison && ( +
+
+
+
Segment A — Avg Sentiment
+
{comparison.segment_a.avg_sentiment.toFixed(3)}
+ + {comparison.segment_a.dominant_sentiment} + +
+ {comparison.segment_a.total_entries} entries • {comparison.segment_a.num_topics} topics +
+
+ +
+
Sentiment Delta
+
+ 0 ? 'var(--success)' : comparison.sentiment_delta < 0 ? 'var(--danger)' : 'var(--text-muted)', + }} + /> + 0 ? 'var(--success)' : comparison.sentiment_delta < 0 ? 'var(--danger)' : 'var(--text-primary)', + }} + > + {comparison.sentiment_delta > 0 ? '+' : ''} + {comparison.sentiment_delta.toFixed(3)} + +
+
+ +
+
Segment B — Avg Sentiment
+
{comparison.segment_b.avg_sentiment.toFixed(3)}
+ + {comparison.segment_b.dominant_sentiment} + +
+ {comparison.segment_b.total_entries} entries • {comparison.segment_b.num_topics} topics +
+
+
+ + {comparison.new_topics.length > 0 && ( +
+
+

New Topics in Segment B

+
+
+ {comparison.new_topics.map((t) => ( + {t.label} + ))} +
+
+ )} + + {comparison.disappeared_topics.length > 0 && ( +
+
+

Topics No Longer in Segment B

+
+
+ {comparison.disappeared_topics.map((t) => ( + {t.label} + ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36e2705b9e2195b59b34e32947e0b83d43827e91 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,657 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useAnalysis } from '../hooks/useAnalysis'; +import { SentimentTrendChart, SentimentDistribution } from '../components/charts/SentimentChart'; +import { TopicBarChart } from '../components/charts/TopicChart'; +import { ForceGraph } from '../components/graphs/ForceGraph'; +import { FilterBar } from '../components/filters/FilterBar'; +import { StatsSkeleton, CardSkeleton } from '../components/common/Skeleton'; +import { Alert } from '../components/common/Alert'; +import { + TrendingUp, MessageSquare, Layers, Globe, Download, + ChevronLeft, ChevronRight, X, Filter, Eye, +} from 'lucide-react'; +import type { AnalyzedEntry, FilterParams, TopicCluster } from '../types'; +import { api } from '../services/api'; + +const PAGE_SIZE = 50; + +export function DashboardPage() { + const { jobs, currentResult, activeJobId, loading, error, loadJobs, selectJob, exportResults, setError } = useAnalysis(); + const [selectedTopic, setSelectedTopic] = useState(null); + + // Pagination & filtering state + const [filters, setFilters] = useState({}); + const [page, setPage] = useState(1); + const [filteredEntries, setFilteredEntries] = useState([]); + const [filteredTotal, setFilteredTotal] = useState(0); + const [tableLoading, setTableLoading] = useState(false); + const [showFilters, setShowFilters] = useState(false); + + // Anomaly expansion + const [expandedAnomaly, setExpandedAnomaly] = useState(null); + + // Entry detail modal + const [selectedEntry, setSelectedEntry] = useState(null); + + useEffect(() => { + loadJobs(); + }, [loadJobs]); + + useEffect(() => { + if (jobs.length > 0 && !activeJobId) { + const completed = jobs.find((j) => j.status === 'completed'); + if (completed) { + selectJob(completed.job_id); + } + } + }, [jobs, activeJobId, selectJob]); + + const result = currentResult; + + // Fetch paginated/filtered entries from backend + const fetchEntries = useCallback(async (jobId: string, f: FilterParams, p: number) => { + setTableLoading(true); + try { + const data = await api.filterResults(jobId, { ...f, page: p, page_size: PAGE_SIZE }); + setFilteredEntries(data.entries); + setFilteredTotal(data.total); + } catch { + // fallback to client-side slice + if (result) { + setFilteredEntries(result.entries.slice((p - 1) * PAGE_SIZE, p * PAGE_SIZE)); + setFilteredTotal(result.entries.length); + } + } finally { + setTableLoading(false); + } + }, [result]); + + // Load entries whenever result, filters, or page changes + useEffect(() => { + if (result?.job_id) { + fetchEntries(result.job_id, filters, page); + } + }, [result?.job_id, filters, page, fetchEntries]); + + const totalPages = Math.ceil(filteredTotal / PAGE_SIZE); + const hasActiveFilters = Object.values(filters).some((v) => + v !== undefined && v !== null && (Array.isArray(v) ? v.length > 0 : true) + ); + + const applyFilter = (newFilters: Partial) => { + setFilters((prev) => ({ ...prev, ...newFilters })); + setPage(1); + }; + + const clearFilters = () => { + setFilters({}); + setPage(1); + }; + + if (loading && !result) { + return ( +
+
+

Dashboard

+

Loading analysis results…

+
+ +
+ + +
+
+ ); + } + + if (!result) { + return ( +
+
+

Dashboard

+

Welcome to Topic Analysis. Upload data to get started.

+
+
+
+ +

No analysis results yet

+

+ Go to Upload Data to analyze customer feedback, support tickets, or reviews. +

+
+
+
+ ); + } + + const summary = result.summary; + const topics = result.topics || []; + const allLanguages = [...new Set(result.entries.map((e) => e.language.language))]; + const allSources = [...new Set(result.entries.map((e) => e.source).filter(Boolean))] as string[]; + const allTopicIds = [...new Set(topics.map((t) => t.topic_id))]; + + return ( +
+
+
+

Dashboard

+

+ Job: {result.job_id} • {result.total_entries} entries analyzed + {result.completed_at && ` • Completed ${new Date(result.completed_at).toLocaleString()}`} +

+
+
+ + + + +
+
+ + {error && setError(null)} />} + + {/* Clickable Anomaly Alerts */} + {result.anomalies.length > 0 && ( +
+ {result.anomalies.slice(0, 5).map((a) => ( +
+
setExpandedAnomaly(expandedAnomaly === a.id ? null : a.id)} + style={{ cursor: 'pointer' }} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && setExpandedAnomaly(expandedAnomaly === a.id ? null : a.id)} + > + 🚨 {a.message} + + {expandedAnomaly === a.id ? '▲ Hide details' : '▼ Click for details'} + +
+ {expandedAnomaly === a.id && ( +
+
+
+
+
Type
+ {a.type.replace('_', ' ')} +
+
+
Detected
+ {new Date(a.detected_at).toLocaleString()} +
+
+
Severity
+ {a.severity} +
+
+ {a.details && Object.keys(a.details).length > 0 && ( +
+
Details
+
+ {Object.entries(a.details).map(([k, v]) => ( +
+ {k}: + {String(v)} +
+ ))} +
+
+ )} + {a.type === 'sentiment_drop' && ( + + )} +
+
+ )} +
+ ))} +
+ )} + + {/* Stats Cards */} +
+
+
+ + Total Entries +
+
{summary?.total_entries || 0}
+
+
+
+ + Avg Sentiment +
+
{summary?.avg_sentiment?.toFixed(3) || '—'}
+ + {summary?.dominant_sentiment || 'N/A'} + +
+
+
+ + Topics Found +
+
{summary?.num_topics || 0}
+
+
+
+ + Languages +
+
{summary?.languages_detected?.length || 0}
+
+ {summary?.languages_detected?.join(', ') || ''} +
+
+
+ + {/* Charts */} +
+
+
+

Sentiment Trend

+
+
+ +
+
+
+
+

Topic Distribution

+
+
+ +
+
+
+ + {/* Topic Graph */} + {result.topic_graph && ( +
+
+

Topic Clusters

+ + Scroll to zoom • Click nodes for details + +
+
+ setSelectedTopic(topic)} + /> +
+
+ )} + + {/* Selected Topic Detail */} + {selectedTopic && ( +
+
+

Topic: {selectedTopic.label}

+
+ + +
+
+
+
+
+
Size
+
{selectedTopic.size} entries
+
+
+
Avg Sentiment
+
{selectedTopic.avg_sentiment.toFixed(3)}
+
+
+
Keywords
+
+ {selectedTopic.keywords.slice(0, 8).map((kw) => ( + {kw} + ))} +
+
+
+
+
Sentiment Distribution
+ +
+ {selectedTopic.representative_docs.length > 0 && ( +
+
Representative Documents
+ {selectedTopic.representative_docs.map((doc, i) => ( +
+ "{doc}" +
+ ))} +
+ )} +
+
+ )} + + {/* Entry Detail Modal */} + {selectedEntry && ( +
setSelectedEntry(null)}> +
e.stopPropagation()}> +
+

Entry Detail

+ +
+
+
+
Full Text
+

+ {selectedEntry.text} +

+
+
+
+
Sentiment
+
+ {selectedEntry.sentiment.label} + {selectedEntry.sentiment.score.toFixed(3)} +
+
+ Confidence: {(selectedEntry.sentiment.confidence * 100).toFixed(1)}% +
+
+
+
Language
+
+ {selectedEntry.language.language} +
+ {(selectedEntry.language.confidence * 100).toFixed(1)}% ({selectedEntry.language.method}) +
+
+
+
+
Topic
+
{selectedEntry.topic_label || 'Uncategorized'}
+
+
+
+ {selectedEntry.source && ( +
+
Source
+
{selectedEntry.source}
+
+ )} + {selectedEntry.timestamp && ( +
+
Timestamp
+
{new Date(selectedEntry.timestamp).toLocaleString()}
+
+ )} +
+
+
+
+ )} + + {/* Entries Table with Filters & Pagination */} +
+
+
+

Entries

+ {hasActiveFilters && ( + Filtered + )} +
+
+ + {filteredTotal === result.entries.length + ? `Showing ${(page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, filteredTotal)} of ${filteredTotal}` + : `Showing ${(page - 1) * PAGE_SIZE + 1}–${Math.min(page * PAGE_SIZE, filteredTotal)} of ${filteredTotal} (filtered from ${result.entries.length})` + } + + + {hasActiveFilters && ( + + )} +
+
+ + {showFilters && ( + { + setFilters(f); + setPage(1); + }} + onReset={clearFilters} + /> + )} + +
+ + + + + + + + + + + + + {filteredEntries.map((entry) => ( + setSelectedEntry(entry)}> + + + + + + + + ))} + {filteredEntries.length === 0 && ( + + + + )} + +
TextSentimentScoreLanguageTopicSource
+ {entry.text} + + { + e.stopPropagation(); + applyFilter({ + sentiment_min: entry.sentiment.label === 'positive' ? 0.6 : entry.sentiment.label === 'negative' ? 0 : 0.35, + sentiment_max: entry.sentiment.label === 'positive' ? 1 : entry.sentiment.label === 'negative' ? 0.35 : 0.6, + }); + }} + title={`Filter by ${entry.sentiment.label}`} + > + {entry.sentiment.label} + + {entry.sentiment.score.toFixed(3)} + { + e.stopPropagation(); + applyFilter({ languages: [entry.language.language] }); + }} + title={`Filter by ${entry.language.language}`} + > + {entry.language.language} + + + { + e.stopPropagation(); + if (entry.topic_id !== -1) { + applyFilter({ topics: [entry.topic_id] }); + } + }} + title={entry.topic_id !== -1 ? `Filter by this topic` : 'Uncategorized'} + style={{ + maxWidth: 150, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', + cursor: entry.topic_id !== -1 ? 'pointer' : 'default', + }} + > + {entry.topic_label || 'Uncategorized'} + + + {entry.source ? ( + { + e.stopPropagation(); + applyFilter({ sources: [entry.source!] }); + }} + title={`Filter by ${entry.source}`} + > + {entry.source} + + ) : ( + + )} +
+ {tableLoading ? 'Loading…' : 'No entries match current filters'} +
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum: number; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (page <= 3) { + pageNum = i + 1; + } else if (page >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = page - 2 + i; + } + return ( + + ); + })} +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/DataQualityPage.tsx b/frontend/src/pages/DataQualityPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f0854ebf2f2a8d1433de731b2fcfe90fdfd787b4 --- /dev/null +++ b/frontend/src/pages/DataQualityPage.tsx @@ -0,0 +1,69 @@ +import { useEffect } from 'react'; +import { useAnalysis } from '../hooks/useAnalysis'; +import { DataQualityPanel } from '../components/quality/DataQualityPanel'; +import { CardSkeleton } from '../components/common/Skeleton'; + +export function DataQualityPage() { + const { jobs, currentResult, activeJobId, loading, loadJobs, selectJob } = useAnalysis(); + + useEffect(() => { + loadJobs(); + }, [loadJobs]); + + useEffect(() => { + if (jobs.length > 0 && !activeJobId) { + const completed = jobs.find((j) => j.status === 'completed'); + if (completed) selectJob(completed.job_id); + } + }, [jobs, activeJobId, selectJob]); + + if (loading && !currentResult) { + return ( +
+
+

Data Quality

+
+ +
+ ); + } + + if (!currentResult?.data_quality) { + return ( +
+
+

Data Quality

+

No data quality report available. Upload and analyze data first.

+
+
+ ); + } + + return ( +
+
+
+

Data Quality

+

Review data quality issues — click cards to drill down into affected entries

+
+ {jobs.filter((j) => j.status === 'completed').length > 1 && ( + + )} +
+ +
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..26a166f1fd77651c003f2c9d50cf399368910d41 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect } from 'react'; +import { Save, Key, Bell, Sliders, RefreshCw, CheckCircle } from 'lucide-react'; +import { Alert } from '../components/common/Alert'; + +interface AppSettings { + apiKey: string; + pageSize: number; + anomalyThreshold: number; + enableNotifications: boolean; + slackWebhookUrl: string; + autoRefreshInterval: number; +} + +const STORAGE_KEY = 'topicanalysis-settings'; + +function loadSettings(): AppSettings { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return { ...defaultSettings, ...JSON.parse(stored) }; + } catch { + // ignore + } + return defaultSettings; +} + +const defaultSettings: AppSettings = { + apiKey: localStorage.getItem('api-key') || 'dev-key-1', + pageSize: 50, + anomalyThreshold: 1.5, + enableNotifications: false, + slackWebhookUrl: '', + autoRefreshInterval: 0, +}; + +export function SettingsPage() { + const [settings, setSettings] = useState(loadSettings); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + const [testingApi, setTestingApi] = useState(false); + const [apiStatus, setApiStatus] = useState<'unknown' | 'ok' | 'error'>('unknown'); + + useEffect(() => { + testApiConnection(settings.apiKey); + }, []); + + const testApiConnection = async (key: string) => { + setTestingApi(true); + try { + const resp = await fetch('/api/v1/jobs', { + headers: { 'X-API-Key': key }, + }); + setApiStatus(resp.ok ? 'ok' : 'error'); + } catch { + setApiStatus('error'); + } finally { + setTestingApi(false); + } + }; + + const handleSave = () => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + localStorage.setItem('api-key', settings.apiKey); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + } catch { + setError('Failed to save settings'); + } + }; + + const handleReset = () => { + setSettings(defaultSettings); + localStorage.removeItem(STORAGE_KEY); + localStorage.setItem('api-key', defaultSettings.apiKey); + setSaved(false); + }; + + const update = (key: K, value: AppSettings[K]) => { + setSettings((prev) => ({ ...prev, [key]: value })); + setSaved(false); + }; + + return ( +
+
+
+

Settings

+

Configure API access, display preferences, and notification settings

+
+
+ + +
+
+ + {error && setError(null)} />} + {saved && ( +
+ + Settings saved. Some changes may require a page reload. +
+ )} + + {/* API Configuration */} +
+
+
+ +

API Configuration

+
+ + {apiStatus === 'ok' ? 'Connected' : apiStatus === 'error' ? 'Error' : 'Unknown'} + +
+
+
+ +
+ update('apiKey', e.target.value)} + placeholder="Enter API key" + /> + +
+ + Default: dev-key-1 for development + +
+
+
+ + {/* Display Preferences */} +
+
+
+ +

Display Preferences

+
+
+
+
+ + +
+
+ + + + Automatically refresh dashboard data at this interval + +
+
+
+ + {/* Anomaly Detection */} +
+
+
+ +

Anomaly Detection & Notifications

+
+
+
+
+ + update('anomalyThreshold', Number(e.target.value))} + step={0.1} + min={0.5} + max={5} + /> + + Alert when sentiment deviates by this many standard deviations (lower = more sensitive) + +
+
+ +
+ {settings.enableNotifications && ( +
+ + update('slackWebhookUrl', e.target.value)} + placeholder="https://hooks.slack.com/services/..." + /> +
+ )} +
+
+ + {/* System Info */} +
+
+

System Information

+
+
+
+
+
Frontend Version
+
1.0.0
+
+
+
API Endpoint
+
/api/v1
+
+
+
Theme
+
+ {document.documentElement.getAttribute('data-theme') || 'light'} +
+
+
+
Storage Used
+
+ {((JSON.stringify(localStorage).length / 1024)).toFixed(1)} KB +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21b12cbc6a4b50c4efe72a6a288006e281cc947b --- /dev/null +++ b/frontend/src/pages/UploadPage.tsx @@ -0,0 +1,98 @@ +import { useAnalysis } from '../hooks/useAnalysis'; +import { FileUpload } from '../components/upload/FileUpload'; +import { Alert } from '../components/common/Alert'; +import { Clock, CheckCircle, XCircle, Loader } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; + +const statusIcons = { + pending: Clock, + processing: Loader, + completed: CheckCircle, + failed: XCircle, +}; + +const statusColors = { + pending: 'var(--text-muted)', + processing: 'var(--info)', + completed: 'var(--success)', + failed: 'var(--danger)', +}; + +export function UploadPage() { + const { jobs, loading, error, uploadFile, pollJobStatus, setError } = useAnalysis(); + const navigate = useNavigate(); + + const handleUpload = async (file: File, source?: string) => { + const status = await uploadFile(file, source); + pollJobStatus(status.job_id, (updated) => { + if (updated.status === 'completed') { + navigate('/'); + } + }); + }; + + return ( +
+
+

Upload Data

+

Upload customer feedback, support tickets, or reviews for analysis

+
+ + {error && setError(null)} />} + + + + {jobs.length > 0 && ( +
+
+

Analysis Jobs

+
+
+ + + + + + + + + + + + + {jobs.map((job) => { + const Icon = statusIcons[job.status]; + return ( + + + + + + + + + ); + })} + +
Job IDStatusProgressCreatedMessage
{job.job_id} +
+ + {job.status} +
+
+
+
+
+
{new Date(job.created_at).toLocaleString()}{job.message} + {job.status === 'completed' && ( + + )} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..745b35851fbaf7d200e5497ef237cf97a1b9e9a7 --- /dev/null +++ b/frontend/src/services/api.ts @@ -0,0 +1,149 @@ +import type { AnalysisResult, ComparisonResult, FilterParams, JobStatus } from '../types'; + +const API_BASE = '/api/v1'; + +class ApiError extends Error { + constructor( + public status: number, + message: string, + public correlationId?: string, + ) { + super(message); + this.name = 'ApiError'; + } +} + +function getHeaders(): Record { + const apiKey = localStorage.getItem('api_key') || 'dev-key-1'; + return { + 'X-API-Key': apiKey, + 'Content-Type': 'application/json', + }; +} + +async function handleResponse(response: Response): Promise { + if (!response.ok) { + const body = await response.json().catch(() => ({ detail: response.statusText })); + throw new ApiError(response.status, body.detail || 'Request failed', body.correlation_id); + } + return response.json(); +} + +export const api = { + async uploadFile(file: File, source?: string): Promise { + const formData = new FormData(); + formData.append('file', file); + + const params = new URLSearchParams(); + if (source) params.set('source', source); + + const apiKey = localStorage.getItem('api_key') || 'dev-key-1'; + const response = await fetch(`${API_BASE}/upload?${params}`, { + method: 'POST', + headers: { 'X-API-Key': apiKey }, + body: formData, + }); + + return handleResponse(response); + }, + + async uploadChunked( + file: File, + onProgress?: (progress: number) => void, + ): Promise { + const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB + const totalChunks = Math.ceil(file.size / CHUNK_SIZE); + let uploadId: string | undefined; + let lastStatus: JobStatus | undefined; + + for (let i = 0; i < totalChunks; i++) { + const start = i * CHUNK_SIZE; + const end = Math.min(start + CHUNK_SIZE, file.size); + const chunk = file.slice(start, end); + + const formData = new FormData(); + formData.append('file', chunk, file.name); + + const params = new URLSearchParams({ + chunk_index: String(i), + total_chunks: String(totalChunks), + }); + if (uploadId) params.set('upload_id', uploadId); + + const apiKey = localStorage.getItem('api_key') || 'dev-key-1'; + const response = await fetch(`${API_BASE}/upload/chunked?${params}`, { + method: 'POST', + headers: { 'X-API-Key': apiKey }, + body: formData, + }); + + lastStatus = await handleResponse(response); + uploadId = lastStatus.job_id; + onProgress?.((i + 1) / totalChunks); + } + + return lastStatus!; + }, + + async getJobs(): Promise { + const response = await fetch(`${API_BASE}/jobs`, { headers: getHeaders() }); + return handleResponse(response); + }, + + async getJobResult(jobId: string): Promise { + const response = await fetch(`${API_BASE}/jobs/${jobId}`, { headers: getHeaders() }); + return handleResponse(response); + }, + + async getJobStatus(jobId: string): Promise { + const response = await fetch(`${API_BASE}/jobs/${jobId}/status`, { headers: getHeaders() }); + return handleResponse(response); + }, + + async filterResults(jobId: string, filters: FilterParams) { + const response = await fetch(`${API_BASE}/jobs/${jobId}/filter`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify(filters), + }); + return handleResponse<{ total: number; page: number; entries: AnalysisResult['entries'] }>(response); + }, + + async compareSegments(jobId: string, segmentA: FilterParams, segmentB: FilterParams): Promise { + const response = await fetch(`${API_BASE}/jobs/${jobId}/compare`, { + method: 'POST', + headers: getHeaders(), + body: JSON.stringify({ segment_a: segmentA, segment_b: segmentB }), + }); + return handleResponse(response); + }, + + async exportResults(jobId: string, format: 'csv' | 'json' | 'pdf', filters?: FilterParams): Promise { + const response = await fetch(`${API_BASE}/jobs/${jobId}/export?fmt=${format}`, { + method: 'POST', + headers: getHeaders(), + body: filters ? JSON.stringify(filters) : '{}', + }); + + if (!response.ok) { + throw new ApiError(response.status, 'Export failed'); + } + return response.blob(); + }, + + subscribeToEvents(onMessage: (data: Record) => void): EventSource { + const apiKey = localStorage.getItem('api_key') || 'dev-key-1'; + const es = new EventSource(`${API_BASE}/events/analysis?api_key=${apiKey}`); + + es.addEventListener('analysis_update', (event) => { + try { + const data = JSON.parse(event.data); + onMessage(data); + } catch { + // ignore parse errors + } + }); + + return es; + }, +}; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000000000000000000000000000000000000..723fbe10a1168fe93b296c9db4e225a2c469e66a --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,614 @@ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-card: #ffffff; + --bg-input: #ffffff; + --text-primary: #1a1a2e; + --text-secondary: #6c757d; + --text-muted: #adb5bd; + --border: #dee2e6; + --border-light: #e9ecef; + --accent: #6366f1; + --accent-hover: #4f46e5; + --success: #10b981; + --warning: #f59e0b; + --danger: #ef4444; + --info: #3b82f6; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.1); + --radius: 8px; + --radius-sm: 4px; + --radius-lg: 12px; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; +} + +[data-theme='dark'] { + --bg-primary: #0f0f23; + --bg-secondary: #1a1a2e; + --bg-card: #16213e; + --bg-input: #1a1a2e; + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --border: #334155; + --border-light: #1e293b; + --accent: #818cf8; + --accent-hover: #6366f1; + --success: #34d399; + --warning: #fbbf24; + --danger: #f87171; + --info: #60a5fa; + --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); + text-decoration: none; +} + +button { + cursor: pointer; + font-family: inherit; +} + +/* Layout */ +.app-layout { + display: flex; + min-height: 100vh; +} + +.sidebar { + width: 240px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + position: fixed; + height: 100vh; + z-index: 100; +} + +.sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--border); +} + +.sidebar-header h1 { + font-size: 16px; + font-weight: 700; + color: var(--accent); +} + +.sidebar-header span { + font-size: 11px; + color: var(--text-muted); +} + +.sidebar-nav { + flex: 1; + padding: 12px; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius); + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + transition: all 0.15s; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.nav-item:hover, +.nav-item.active { + background: var(--accent); + color: white; +} + +.nav-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.sidebar-footer { + padding: 12px; + border-top: 1px solid var(--border); +} + +.main-content { + flex: 1; + margin-left: 240px; + padding: 24px; + min-height: 100vh; +} + +/* Cards */ +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); + display: flex; + align-items: center; + justify-content: space-between; +} + +.card-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.card-body { + padding: 20px; +} + +/* Grid */ +.grid { + display: grid; + gap: 20px; +} + +.grid-2 { grid-template-columns: repeat(2, 1fr); } +.grid-3 { grid-template-columns: repeat(3, 1fr); } +.grid-4 { grid-template-columns: repeat(4, 1fr); } + +@media (max-width: 1024px) { + .grid-4 { grid-template-columns: repeat(2, 1fr); } + .grid-3 { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 768px) { + .sidebar { display: none; } + .main-content { margin-left: 0; } + .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; } +} + +/* Stats */ +.stat-card { + padding: 20px; +} + +.stat-label { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.stat-value { + font-size: 28px; + font-weight: 700; + margin-top: 4px; + color: var(--text-primary); +} + +.stat-change { + font-size: 12px; + margin-top: 4px; +} + +.stat-change.positive { color: var(--success); } +.stat-change.negative { color: var(--danger); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--radius); + font-size: 14px; + font-weight: 500; + border: 1px solid transparent; + transition: all 0.15s; +} + +.btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.btn-primary { + background: var(--accent); + color: white; +} + +.btn-primary:hover { background: var(--accent-hover); } + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border); +} + +.btn-secondary:hover { background: var(--border-light); } + +.btn-danger { + background: var(--danger); + color: white; +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +.btn-icon { + padding: 8px; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text-secondary); +} + +.btn-icon:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* Form elements */ +.input { + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input); + color: var(--text-primary); + font-size: 14px; + width: 100%; + transition: border-color 0.15s; +} + +.input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.select { + appearance: none; + padding: 8px 32px 8px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-input) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236c757d' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center; + color: var(--text-primary); + font-size: 14px; + cursor: pointer; +} + +.label { + display: block; + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 4px; +} + +/* Badge */ +.badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} + +.badge-positive { background: rgba(16, 185, 129, 0.1); color: var(--success); } +.badge-negative { background: rgba(239, 68, 68, 0.1); color: var(--danger); } +.badge-neutral { background: rgba(107, 114, 128, 0.1); color: var(--text-secondary); } +.badge-info { background: rgba(59, 130, 246, 0.1); color: var(--info); } +.badge-warning { background: rgba(245, 158, 11, 0.1); color: var(--warning); } + +/* Skeleton loading */ +.skeleton { + background: linear-gradient(90deg, var(--border-light) 25%, var(--bg-secondary) 50%, var(--border-light) 75%); + background-size: 200% 100%; + animation: skeleton-loading 1.5s infinite; + border-radius: var(--radius-sm); +} + +@keyframes skeleton-loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +.skeleton-text { height: 14px; margin-bottom: 8px; } +.skeleton-heading { height: 28px; width: 60%; margin-bottom: 12px; } +.skeleton-chart { height: 200px; } +.skeleton-card { height: 100px; } + +/* Table */ +.table-wrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.table th { + padding: 10px 12px; + text-align: left; + font-weight: 600; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + border-bottom: 1px solid var(--border); +} + +.table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-light); + color: var(--text-secondary); +} + +.table tr:hover td { + background: var(--bg-secondary); +} + +/* Upload */ +.upload-zone { + border: 2px dashed var(--border); + border-radius: var(--radius-lg); + padding: 48px 24px; + text-align: center; + transition: all 0.2s; + cursor: pointer; +} + +.upload-zone:hover, +.upload-zone.drag-over { + border-color: var(--accent); + background: rgba(99, 102, 241, 0.05); +} + +.upload-zone h3 { + font-size: 16px; + margin-top: 12px; + color: var(--text-primary); +} + +.upload-zone p { + font-size: 13px; + color: var(--text-muted); + margin-top: 4px; +} + +/* Progress */ +.progress-bar { + height: 6px; + background: var(--border-light); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.3s ease; +} + +/* Alerts */ +.alert { + padding: 12px 16px; + border-radius: var(--radius); + font-size: 13px; + display: flex; + align-items: center; + gap: 10px; +} + +.alert-danger { + background: rgba(239, 68, 68, 0.1); + color: var(--danger); + border: 1px solid rgba(239, 68, 68, 0.2); +} + +.alert-warning { + background: rgba(245, 158, 11, 0.1); + color: var(--warning); + border: 1px solid rgba(245, 158, 11, 0.2); +} + +.alert-success { + background: rgba(16, 185, 129, 0.1); + color: var(--success); + border: 1px solid rgba(16, 185, 129, 0.2); +} + +/* Filters bar */ +.filters-bar { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: flex-end; + padding: 16px 20px; + background: var(--bg-secondary); + border-radius: var(--radius-lg); + margin-bottom: 20px; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Tooltip */ +.tooltip { + position: relative; +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Page transitions */ +.page-header { + margin-bottom: 24px; +} + +.page-header h2 { + font-size: 24px; + font-weight: 700; +} + +.page-header p { + color: var(--text-secondary); + font-size: 14px; + margin-top: 4px; +} + +/* Pagination */ +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 12px 20px; + border-top: 1px solid var(--border-light); +} + +.pagination-pages { + display: flex; + gap: 4px; +} + +/* Clickable elements */ +.clickable-badge { + cursor: pointer; + transition: transform 0.1s, box-shadow 0.15s; +} + +.clickable-badge:hover { + transform: scale(1.1); + box-shadow: 0 0 0 2px var(--accent); +} + +.clickable-text { + cursor: pointer; + transition: color 0.15s; +} + +.clickable-text:hover { + color: var(--accent) !important; + text-decoration: underline; +} + +.clickable-row { + cursor: pointer; +} + +.clickable-row:hover td { + background: var(--bg-secondary) !important; +} + +.clickable-alert { + transition: opacity 0.15s; +} + +.clickable-alert:hover { + opacity: 0.85; +} + +.clickable-card { + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.clickable-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.clickable-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Modal overlay */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; +} + +.modal-content { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + max-width: 640px; + width: 100%; + max-height: 80vh; + overflow-y: auto; +} + +/* Flex utilities */ +.flex { display: flex; } +.flex-col { flex-direction: column; } +.items-center { align-items: center; } +.justify-between { justify-content: space-between; } +.gap-2 { gap: 8px; } +.gap-3 { gap: 12px; } +.gap-4 { gap: 16px; } +.mt-4 { margin-top: 16px; } +.mb-4 { margin-bottom: 16px; } diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b0828bfa80fb3c4504510349e22dc4cf5bc0a7b --- /dev/null +++ b/frontend/src/test-setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..69b07b2325e13d17d2e37f7a6057a71f482d2414 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,142 @@ +export interface SentimentResult { + label: 'positive' | 'negative' | 'neutral'; + score: number; + confidence: number; +} + +export interface LanguageResult { + language: string; + confidence: number; + method: string; +} + +export interface AnalyzedEntry { + id: string; + text: string; + source?: string; + timestamp?: string; + sentiment: SentimentResult; + language: LanguageResult; + topic_id: number; + topic_label: string; + metadata?: Record; +} + +export interface TopicCluster { + topic_id: number; + label: string; + keywords: string[]; + size: number; + avg_sentiment: number; + sentiment_distribution: Record; + languages: Record; + representative_docs: string[]; +} + +export interface TopicLink { + source: number; + target: number; + weight: number; +} + +export interface TopicGraph { + nodes: TopicCluster[]; + links: TopicLink[]; +} + +export interface SentimentTrend { + period: string; + avg_sentiment: number; + count: number; + positive: number; + negative: number; + neutral: number; + confidence_lower: number; + confidence_upper: number; +} + +export interface DataQualityReport { + total_entries: number; + low_confidence_count: number; + low_confidence_entries: string[]; + mixed_language_count: number; + mixed_language_entries: string[]; + duplicate_count: number; + duplicate_entries: string[]; + avg_confidence: number; + language_distribution: Record; +} + +export interface AnomalyAlert { + id: string; + type: 'sentiment_drop' | 'topic_spike'; + severity: string; + message: string; + detected_at: string; + details: Record; +} + +export interface TopicInfo { + topic_id: number; + label: string; + keywords: string[]; + size: number; +} + +export interface AnalysisSummary { + total_entries: number; + avg_sentiment: number; + dominant_sentiment: string; + num_topics: number; + top_topics: TopicInfo[]; + languages_detected: string[]; + date_range?: { start: string; end: string }; +} + +export interface AnalysisResult { + job_id: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + created_at: string; + completed_at?: string; + total_entries: number; + entries: AnalyzedEntry[]; + topics: TopicCluster[]; + sentiment_trends: SentimentTrend[]; + topic_graph?: TopicGraph; + data_quality?: DataQualityReport; + anomalies: AnomalyAlert[]; + summary?: AnalysisSummary; +} + +export interface JobStatus { + job_id: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; + message: string; + created_at: string; + completed_at?: string; +} + +export interface FilterParams { + date_from?: string; + date_to?: string; + sentiment_min?: number; + sentiment_max?: number; + topics?: number[]; + languages?: string[]; + sources?: string[]; + search_text?: string; + page?: number; + page_size?: number; +} + +export interface ComparisonResult { + segment_a: AnalysisSummary; + segment_b: AnalysisSummary; + sentiment_delta: number; + topic_changes: Record[]; + new_topics: TopicInfo[]; + disappeared_topics: TopicInfo[]; +} + +export type Theme = 'light' | 'dark'; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..11f02fe2a0061d6e6e1f271b21da95423b448b32 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..b2e869889d693c6bb37f9ff9e8190d16e7437012 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "types": ["vitest/globals"] + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d98cdbce45d83dbfbaf7057f515a802b27b63b1 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test-setup.ts', + css: true, + }, +});