From 867c06295c219aa6618c72e5168133a82cfcbb83 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Fri, 24 Apr 2026 08:02:13 +0200 Subject: [PATCH] Initial commit --- .gitignore | 318 + AGENTS.md | 72 + README.md | 75 + SPEC.md | 564 ++ backend/Dockerfile | 17 + backend/app/config.py | 13 + backend/app/crud.py | 113 + backend/app/database.py | 23 + backend/app/main.py | 28 + backend/app/models.py | 14 + backend/app/routers/sessions.py | 103 + backend/app/routers/stats.py | 159 + backend/app/schemas.py | 59 + backend/app/utils/time_utils.py | 64 + backend/pyproject.toml | 34 + backend/tests/conftest.py | 47 + backend/tests/test_bug_repro.py | 69 + backend/tests/test_sessions.py | 111 + backend/tests/test_sessions_fixed.py | 68 + backend/tests/test_stats.py | 73 + backend/tests/test_time_utils.py | 121 + backend/uv.lock | 442 ++ backend_log.txt | 14 + docker-compose.yml | 24 + frontend/.dockerignore | 7 + frontend/Dockerfile | 30 + frontend/frontend_log.txt | 317 + frontend/index.html | 13 + frontend/nginx.conf | 26 + frontend/package-lock.json | 5277 +++++++++++++++++ frontend/package.json | 39 + frontend/playwright.config.ts | 39 + frontend/src/App.tsx | 24 + frontend/src/api/AGENTS.md | 16 + frontend/src/api/client.ts | 100 + frontend/src/components/ErrorBoundary.tsx | 46 + frontend/src/components/Navbar.tsx | 32 + frontend/src/components/stats/AGENTS.md | 21 + frontend/src/components/stats/BedtimePlot.tsx | 93 + .../src/components/stats/DailySummary.tsx | 120 + .../src/components/stats/IntervalPicker.tsx | 28 + frontend/src/components/stats/SessionList.tsx | 49 + .../src/components/stats/SettingsPopover.tsx | 118 + .../src/components/stats/SleepTimetable.tsx | 53 + frontend/src/components/stats/StatsPage.tsx | 94 + frontend/src/components/stats/WakeupPlot.tsx | 93 + .../src/components/test-error-component.tsx | 7 + .../src/components/tracker/SleepButton.tsx | 52 + .../src/components/tracker/StatusBanner.tsx | 42 + .../src/components/tracker/TimeAdjuster.tsx | 58 + .../src/components/tracker/TrackerPage.tsx | 22 + frontend/src/hooks/AGENTS.md | 19 + frontend/src/hooks/useSettings.ts | 44 + frontend/src/hooks/useSleepSession.ts | 73 + frontend/src/hooks/useStats.ts | 34 + frontend/src/index.css | 1 + frontend/src/main.tsx | 15 + frontend/src/types/index.ts | 71 + frontend/src/utils/chartHelpers.ts | 146 + frontend/src/utils/time.ts | 118 + frontend/stats-tab-test.png | Bin 0 -> 4254 bytes frontend/tailwind.config.ts | 13 + frontend/tests/e2e/connectivity.spec.ts | 7 + frontend/tests/e2e/smoke.spec.ts | 23 + frontend/tests/e2e/stats.spec.ts | 71 + frontend/tests/e2e/stop-sleep.spec.ts | 21 + frontend/tests/e2e/tracker.spec.ts | 94 + frontend/tests/simple.test.ts | 5 + frontend/tests/unit/SleepButton.test.tsx | 71 + frontend/tests/unit/TimeAdjuster.test.tsx | 30 + frontend/tests/unit/chartHelpers.test.ts | 113 + frontend/tests/unit/time.test.ts | 106 + frontend/vite.config.ts | 25 + test-results/.last-run.json | 4 + test_asgi.py | 12 + test_db.py | 16 + 76 files changed, 10573 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 SPEC.md create mode 100644 backend/Dockerfile create mode 100644 backend/app/config.py create mode 100644 backend/app/crud.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/routers/sessions.py create mode 100644 backend/app/routers/stats.py create mode 100644 backend/app/schemas.py create mode 100644 backend/app/utils/time_utils.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_bug_repro.py create mode 100644 backend/tests/test_sessions.py create mode 100644 backend/tests/test_sessions_fixed.py create mode 100644 backend/tests/test_stats.py create mode 100644 backend/tests/test_time_utils.py create mode 100644 backend/uv.lock create mode 100644 backend_log.txt create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/frontend_log.txt create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/AGENTS.md create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/ErrorBoundary.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/stats/AGENTS.md create mode 100644 frontend/src/components/stats/BedtimePlot.tsx create mode 100644 frontend/src/components/stats/DailySummary.tsx create mode 100644 frontend/src/components/stats/IntervalPicker.tsx create mode 100644 frontend/src/components/stats/SessionList.tsx create mode 100644 frontend/src/components/stats/SettingsPopover.tsx create mode 100644 frontend/src/components/stats/SleepTimetable.tsx create mode 100644 frontend/src/components/stats/StatsPage.tsx create mode 100644 frontend/src/components/stats/WakeupPlot.tsx create mode 100644 frontend/src/components/test-error-component.tsx create mode 100644 frontend/src/components/tracker/SleepButton.tsx create mode 100644 frontend/src/components/tracker/StatusBanner.tsx create mode 100644 frontend/src/components/tracker/TimeAdjuster.tsx create mode 100644 frontend/src/components/tracker/TrackerPage.tsx create mode 100644 frontend/src/hooks/AGENTS.md create mode 100644 frontend/src/hooks/useSettings.ts create mode 100644 frontend/src/hooks/useSleepSession.ts create mode 100644 frontend/src/hooks/useStats.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/src/utils/chartHelpers.ts create mode 100644 frontend/src/utils/time.ts create mode 100644 frontend/stats-tab-test.png create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tests/e2e/connectivity.spec.ts create mode 100644 frontend/tests/e2e/smoke.spec.ts create mode 100644 frontend/tests/e2e/stats.spec.ts create mode 100644 frontend/tests/e2e/stop-sleep.spec.ts create mode 100644 frontend/tests/e2e/tracker.spec.ts create mode 100644 frontend/tests/simple.test.ts create mode 100644 frontend/tests/unit/SleepButton.test.tsx create mode 100644 frontend/tests/unit/TimeAdjuster.test.tsx create mode 100644 frontend/tests/unit/chartHelpers.test.ts create mode 100644 frontend/tests/unit/time.test.ts create mode 100644 frontend/vite.config.ts create mode 100644 test-results/.last-run.json create mode 100644 test_asgi.py create mode 100644 test_db.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c03d2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,318 @@ +# Created by https://www.toptal.com/developers/gitignore/api/node,python +# Edit at https://www.toptal.com/developers/gitignore?templates=node,python + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# LSP config files +pyrightconfig.json + +# End of https://www.toptal.com/developers/gitignore/api/node,python +.sisyphus +.sisypus +frontend/test-results +frontend/playwright-report +backend/data diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38be9cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,72 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2026-04-23 +**Mode:** init-deep (create-new) + +## OVERVIEW + +Baby Sleep Tracker - self-hosted web app for tracking infant sleep. Dual-stack monorepo: React 19/Vite 6/TypeScript frontend + Python 3.14/FastAPI backend. Optimized for Raspberry Pi 4 (ARM64). + +## STRUCTURE +``` +./ +├── frontend/ # React 19 SPA +│ ├── src/ +│ │ ├── api/ # React Query hooks +│ │ ├── components/ +│ │ │ ├── stats/ # Charts, grids, trends (8 files - highest density) +│ │ │ └── tracker/ # Sleep session controls +│ │ ├── hooks/ # Custom React hooks +│ │ ├── types/ # TypeScript interfaces +│ │ └── utils/ +│ └── tests/ # e2e (Playwright), unit (Vitest) +├── backend/ # FastAPI + SQLModel +│ ├── app/ +│ │ ├── routers/ # API endpoints +│ │ └── utils/ +│ └── tests/ # pytest + asyncio +└── docker-compose.yml +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Add sleep session | `backend/app/crud.py` | `create_session`, `update_session` | +| Fetch sleep data | `frontend/src/api/` | React Query hooks for sessions | +| UI components | `frontend/src/components/` | tracker/, stats/ subdirs | +| Backend models | `backend/app/models.py` | SQLModel definitions | +| API schemas | `backend/app/schemas.py` | Pydantic request/response | + +## CONVENTIONS + +- **Backend**: SQLModel (not pure SQLAlchemy), async aiosqlite, pytest-asyncio +- **Frontend**: React 19, TanStack Query v5, Tailwind v4, no ESLint config (yet) +- **Python**: >=3.14 required, Hatch for packaging +- **No CI/CD**: No .github/workflows, no Makefiles yet + +## ANTI-PATTERNS (THIS PROJECT) + +- No type suppression (`as any`, `@ts-ignore`, `@ts-expect-error`) - type safety enforced +- No empty catch blocks - error handling required +- No deleting failing tests to "pass" + +## COMMANDS + +```bash +# Backend +cd backend && uv sync +uv run uvicorn app.main:app --reload +uv run pytest + +# Frontend +cd frontend && npm install +npm run dev +npm run test +``` + +## NOTES + +- Entry point: `frontend/src/main.tsx` + `backend/app/main.py` +- DB: SQLite via aiosqlite (async-first backend) +- Build: Docker for ARM64 Raspberry Pi deployment \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..75bee38 --- /dev/null +++ b/README.md @@ -0,0 +1,75 @@ +# Baby Sleep Tracker + +A self-hosted web application for tracking infant sleep sessions, optimized for Raspberry Pi 4 (ARM64). + +## Features + +- **Tracker Tab**: Start and stop sleep sessions with a single click. Adjust start/end times easily. +- **Stats Tab**: Visualize sleep patterns with daily summaries (day vs. night), continuous time-slot grids, and wakeup/bedtime trend plots. +- **Responsive Design**: Built for mobile use via a clean, thumb-friendly UI. +- **Dockerized**: Easy deployment using Docker Compose. + +## Tech Stack + +- **Frontend**: React 19, Vite 6, TypeScript, Tailwind CSS v4, Recharts, TanStack Query. +- **Backend**: Python 3.12+, FastAPI, SQLModel (SQLAlchemy + P/Dantic), SQLite. +- **Deployment**: Docker, Docker Compose. + +## Getting Started + +### Prerequisites + +- [Docker](https://docs.docker.com/get-docker/) +- [Docker Compose](https://docs.docker.com/compose/install/) + +*Note: If building on an x86 machine for ARM64 (Raspberry Pi), ensure you use `docker buildx`.* + +### Running with Docker Compose + +1. Clone the repository. +2. Navigate to the project root. +3. Run the following command: + +```bash +docker compose up --build +``` + +The application will be available at: +- **Frontend**: [http://localhost](http://localhost) +- **Backend API**: [http://localhost:8000](http://localhost:8000) + +### Development Setup + +#### Backend + +Requires `uv` and Python 3.14+. + +```bash +cd backend +uv sync +uv run uvicorn app.main:app --reload +``` + +Run tests: +```bash +uv run pytest +``` + +#### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Run tests: +```bash +npm run test +``` + +## Project Structure + +- `backend/`: FastAPI application, models, and API routes. +- `frontend/`: React application with components and hooks. +- `docker-compose.yml`: Orchestration for the full stack. diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..b13fca9 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,564 @@ +# Baby Sleep Tracker — Full Application Specification + +## Overview + +A self-hosted web application for tracking infant sleep sessions. It runs on a Raspberry Pi 4 (ARM64) via Docker Compose and consists of a FastAPI backend (SQLite), a React + Vite frontend, and end-to-end Playwright tests. + +--- + +## Tech Stack + +| Layer | Choice | Rationale | +|---|---|---| +| Frontend | React 19 + Vite 6 + TypeScript | Latest stable; fast HMR; excellent ecosystem | +| Styling | Tailwind CSS v4 | Utility-first; tiny output; works well with Vite | +| Charts | Recharts | Composable; zero-dep; works in React 19 | +| State / data fetching | TanStack Query v5 | Server-state management; caching; optimistic updates | +| Backend | Python 3.12 + FastAPI | Async; auto-generated OpenAPI; lightweight | +| ORM | SQLModel (wraps SQLAlchemy + Pydantic) | Single model definition for DB + API schemas | +| Database | SQLite (via aiosqlite for async) | Zero-ops; file-based; perfect for Pi | +| Testing — backend | pytest + httpx (async) | Fast; integrates with FastAPI's TestClient | +| Testing — frontend | Vitest + React Testing Library | Co-located unit/component tests | +| Testing — E2E | Playwright | Cross-browser; works in CI and dev | +| Container | Docker (multi-stage, linux/arm64) | Slim images for Pi 4 | +| Orchestration | Docker Compose v2 | Single-command startup | + +--- + +## Repository Layout + +``` +baby-sleep-tracker/ +├── backend/ +│ ├── app/ +│ │ ├── __init__.py +│ │ ├── main.py # FastAPI app factory + lifespan +│ │ ├── config.py # Pydantic Settings (env vars) +│ │ ├── database.py # Async engine + session factory +│ │ ├── models.py # SQLModel table definitions +│ │ ├── schemas.py # Request / response Pydantic models +│ │ ├── crud.py # All DB operations (pure functions) +│ │ ├── routers/ +│ │ │ ├── __init__.py +│ │ │ ├── sessions.py # /api/sessions +│ │ │ └── stats.py # /api/stats +│ │ └── utils/ +│ │ ├── __init__.py +│ │ └── time_utils.py # Day/night boundary helpers +│ ├── tests/ +│ │ ├── conftest.py # In-memory SQLite fixtures +│ │ ├── test_sessions.py +│ │ ├── test_stats.py +│ │ └── test_time_utils.py +│ ├── Dockerfile +│ ├── pyproject.toml +│ └── requirements.txt +│ +├── frontend/ +│ ├── src/ +│ │ ├── main.tsx +│ │ ├── App.tsx # Tab routing (Tracker / Stats) +│ │ ├── api/ +│ │ │ └── client.ts # Typed fetch wrappers (no extra lib) +│ │ ├── components/ +│ │ │ ├── tracker/ +│ │ │ │ ├── SleepButton.tsx +│ │ │ │ ├── TimeAdjuster.tsx +│ │ │ │ └── StatusBanner.tsx +│ │ │ └── stats/ +│ │ │ ├── DailySummary.tsx +│ │ │ ├── SleepTimetable.tsx +│ │ │ ├── WakeupPlot.tsx +│ │ │ ├── BedtimePlot.tsx +│ │ │ └── IntervalPicker.tsx +│ │ ├── hooks/ +│ │ │ ├── useSleepSession.ts +│ │ │ └── useStats.ts +│ │ ├── types/ +│ │ │ └── index.ts # Shared TypeScript interfaces +│ │ └── utils/ +│ │ ├── time.ts # Format helpers +│ │ └── chartHelpers.ts +│ ├── tests/ +│ │ ├── unit/ +│ │ │ ├── SleepButton.test.tsx +│ │ │ └── time.test.ts +│ │ └── e2e/ # Playwright tests +│ │ ├── tracker.spec.ts +│ │ └── stats.spec.ts +│ ├── playwright.config.ts +│ ├── vite.config.ts +│ ├── tailwind.config.ts +│ ├── tsconfig.json +│ ├── package.json +│ └── Dockerfile +│ +├── docker-compose.yml +└── README.md +``` + +--- + +## Backend Specification + +### Data Model + +#### Table: `sleep_session` + +| Column | Type | Notes | +|---|---|---| +| id | INTEGER | Primary key, auto-increment | +| start_time | DATETIME | UTC, not null | +| end_time | DATETIME | UTC, nullable — null means session is ongoing | +| notes | TEXT | Optional free-text | +| created_at | DATETIME | UTC, server default now() | +| updated_at | DATETIME | UTC, auto-update on write | + +No other tables are required. Settings (day/night boundary, plot reference times) are stored client-side in `localStorage` and passed as query parameters where needed. + +--- + +### API Endpoints + +#### `POST /api/sessions` + +Start a new sleep session. Rejects if there is already an open session. + +**Request body** +```json +{ + "start_time": "2024-01-15T21:30:00Z" // ISO-8601, required +} +``` + +**Response 201** +```json +{ + "id": 1, + "start_time": "2024-01-15T21:30:00Z", + "end_time": null, + "notes": null, + "created_at": "...", + "updated_at": "..." +} +``` + +**Error 409** — session already open. + +--- + +#### `PATCH /api/sessions/{id}/end` + +End an existing session. + +**Request body** +```json +{ + "end_time": "2024-01-16T06:45:00Z" // must be after start_time +} +``` + +**Response 200** — full session object. + +**Error 400** — end_time before start_time. +**Error 404** — session not found. +**Error 409** — session already ended. + +--- + +#### `GET /api/sessions/active` + +Returns the currently open session or `null`. + +**Response 200** +```json +{ + "session": { ... } | null +} +``` + +--- + +#### `GET /api/sessions` + +List sessions, most recent first. + +**Query params** +- `from_date` — ISO-8601 date (inclusive) +- `to_date` — ISO-8601 date (inclusive) +- `limit` — default 200 + +**Response 200** +```json +{ + "sessions": [ ... ], + "total": 42 +} +``` + +--- + +#### `PUT /api/sessions/{id}` + +Update start_time, end_time, or notes on any session. + +**Request body** — all fields optional. + +--- + +#### `DELETE /api/sessions/{id}` + +Hard-delete a session. + +--- + +#### `GET /api/stats/daily-summary` + +Returns total sleep per day, split into day/night, for the given interval. + +**Query params** +- `from_date` — required ISO-8601 date +- `to_date` — required ISO-8601 date +- `day_start` — HH:MM, default `07:00` +- `day_end` — HH:MM, default `20:00` + +**Response 200** +```json +{ + "days": [ + { + "date": "2024-01-15", + "total_minutes": 720, + "day_minutes": 120, + "night_minutes": 600 + } + ] +} +``` + +The backend clips each session into day vs night segments using the provided boundary times. Sessions crossing midnight are handled correctly. + +--- + +#### `GET /api/stats/timetable` + +Returns sleep coverage per 15-minute slot for each day in the interval. + +**Query params** — same as daily-summary. + +**Response 200** +```json +{ + "slots": [ + { + "date": "2024-01-15", + "slot_start": "00:00", + "slot_end": "00:15", + "sleeping": true + }, + ... + ] +} +``` + +--- + +#### `GET /api/stats/wake-sleep-times` + +Returns first wakeup and last sleep-start time for each day, expressed as minutes after a reference time. + +**Query params** +- `from_date`, `to_date` — required +- `wakeup_ref` — HH:MM, default `04:00` +- `bedtime_ref` — HH:MM, default `19:00` + +**Response 200** +```json +{ + "days": [ + { + "date": "2024-01-15", + "wakeup_minutes": 195, + "bedtime_minutes": 75 + } + ] +} +``` + +`wakeup_minutes` = minutes after `wakeup_ref` of the first session end (i.e. first time baby woke up). +`bedtime_minutes` = minutes after `bedtime_ref` of the last session start (i.e. last time baby fell asleep). +Either value is `null` if no qualifying session exists for that day. + +--- + +### Configuration (`config.py`) + +Read from environment variables with sane defaults: + +``` +DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db +CORS_ORIGINS=http://localhost:5173 +LOG_LEVEL=info +``` + +--- + +### Backend Tests (`pytest`) + +All tests use an in-memory SQLite database created fresh per test via a `pytest` fixture. + +**`test_sessions.py`** +- Create session → 201 +- Create second session while one is open → 409 +- End session with valid end_time → 200 +- End session with end_time before start_time → 400 +- End already-ended session → 409 +- List sessions respects date filters +- DELETE removes session + +**`test_stats.py`** +- Daily summary: session entirely in day → only day_minutes populated +- Daily summary: session spanning day/night boundary → correctly split +- Daily summary: session spanning midnight → attributed to correct calendar day +- Timetable: 15-minute slots correctly marked +- Wake/sleep times: first wakeup and last bedtime correctly computed +- All stats return empty arrays for intervals with no data + +**`test_time_utils.py`** +- Day/night split logic unit tested in isolation +- Minutes-after-reference calculations +- Edge cases: midnight crossing, DST handling (pass explicit tzinfo) + +--- + +## Frontend Specification + +### Global Settings (persisted in `localStorage`) + +| Key | Type | Default | +|---|---|---| +| `dayStart` | `"HH:MM"` | `"07:00"` | +| `dayEnd` | `"HH:MM"` | `"20:00"` | +| `wakeupRef` | `"HH:MM"` | `"04:00"` | +| `bedtimeRef` | `"HH:MM"` | `"19:00"` | +| `statsInterval` | `7 \| 14 \| 30 \| 90` | `7` | + +--- + +### Tab 1 — Tracker + +#### Layout +- Full-height centered column, comfortable on a phone. +- Top: `StatusBanner` — shows current state. +- Middle: `TimeAdjuster` — editable datetime field pre-filled to "now". +- Bottom: `SleepButton` — primary CTA. + +#### `StatusBanner` + +- **No active session**: "Baby is awake 👶" with elapsed time since last sleep ended (if known). +- **Active session**: "Baby is sleeping 😴" + elapsed time since session start, updated every second. + +#### `TimeAdjuster` + +- `` pre-filled to current local time. +- Resets to "now" each time the state changes (start → stop or stop → start), but the user can freely edit before confirming. +- Small "Reset to now" link beside the field. + +#### `SleepButton` + +- **Awake state**: Large button labeled **"Start Sleep"** in a calm night-blue. +- **Sleeping state**: Large button labeled **"Stop Sleep"** in a warm amber. +- On click: reads the time from `TimeAdjuster`, calls the appropriate API, then updates UI via TanStack Query invalidation. +- Button is disabled while a mutation is in flight (shows spinner). + +--- + +### Tab 2 — Stats + +#### `IntervalPicker` + +Segmented control always visible at the top: **7d · 14d · 30d · 90d**. Updates all charts simultaneously. + +#### `DailySummary` + +A stacked horizontal bar chart (Recharts `BarChart` with `layout="vertical"`): +- Y-axis: dates +- Each bar has two segments: day sleep (warm yellow) and night sleep (navy) +- Total label at end of bar + +Below the chart: three summary numbers for the selected interval — average total sleep, average day sleep, average night sleep. + +A small gear icon opens an inline settings popover to change `dayStart` / `dayEnd`. + +#### `SleepTimetable` + +A grid where: +- Rows = days (newest at top) +- Columns = 15-minute slots across 24 hours (96 columns) +- Each cell is a small coloured square: sleeping = navy, awake = light grey +- X-axis labels: 00:00, 03:00, 06:00 … 21:00 +- Scrollable horizontally on mobile + +#### `WakeupPlot` and `BedtimePlot` + +Side-by-side (stacked on mobile) `LineChart` components: +- X-axis: days ago (0 = today, increasing to the left) +- Y-axis: minutes after reference time, formatted as clock time (e.g. 195 min after 04:00 → "07:15") +- Dots shown at each data point; `null` days leave a gap +- Small settings popover to change `wakeupRef` / `bedtimeRef` + +--- + +### Frontend Tests + +#### Unit / component (`Vitest` + React Testing Library) + +- `SleepButton`: renders "Start Sleep" when no active session; renders "Stop Sleep" when session active; calls correct mutation on click; shows spinner during loading. +- `TimeAdjuster`: defaults to current time; "Reset to now" resets the field; emits onChange on edit. +- `time.ts` utils: `formatMinutesAfter`, `toLocalDatetimeInputValue`, `clampToDay` etc. +- `chartHelpers.ts`: `buildTimetableSlots`, `computeWakeupMinutes`, `computeBedtimeMinutes`. + +#### E2E (`Playwright`) + +**`tracker.spec.ts`** +1. Page loads — "Start Sleep" button is visible. +2. Click "Start Sleep" — button changes to "Stop Sleep", status banner updates. +3. Adjust start time to 5 minutes ago — verify API receives adjusted time. +4. Click "Stop Sleep" — button returns to "Start Sleep". +5. Reload page — UI correctly reflects state from API (no active session). + +**`stats.spec.ts`** +1. Stats tab is reachable via tab navigation. +2. Interval picker changes interval (mocked API). +3. Timetable renders correct number of rows for selected interval. +4. Wakeup and bedtime plots render SVG lines. +5. Settings popover for day/night boundary saves to localStorage and re-fetches stats. + +Playwright is configured to run against the Vite dev server (`webServer` option in `playwright.config.ts`) so tests can run during development with `npx playwright test --ui`. + +--- + +## Docker + +### `backend/Dockerfile` + +```dockerfile +FROM python:3.12-slim AS base +WORKDIR /app + +FROM base AS builder +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM base +COPY --from=builder /install /usr/local +COPY app/ ./app/ +RUN mkdir -p /app/data +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +Build with `--platform linux/arm64`. + +### `frontend/Dockerfile` + +```dockerfile +FROM node:22-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:1.27-alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +``` + +`nginx.conf` serves `index.html` for all routes (SPA fallback) and proxies `/api` to the backend container. + +Build with `--platform linux/arm64`. + +--- + +## `docker-compose.yml` + +```yaml +services: + backend: + build: + context: ./backend + platforms: [linux/arm64] + volumes: + - sleep_data:/app/data + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db + - CORS_ORIGINS=http://localhost + restart: unless-stopped + + frontend: + build: + context: ./frontend + platforms: [linux/arm64] + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + sleep_data: +``` + +--- + +## Development Workflow + +```bash +# Backend +cd backend +pip install -e ".[dev]" +uvicorn app.main:app --reload + +# Frontend +cd frontend +npm install +npm run dev # Vite dev server on :5173 + +# Backend tests +pytest -v + +# Frontend unit tests +npm run test # Vitest + +# E2E tests (requires both servers running) +npx playwright test +npx playwright test --ui # interactive mode during dev +``` + +`vite.config.ts` proxies `/api` → `http://localhost:8000` in dev mode so the frontend can call the backend without CORS issues during development. + +--- + +## Implementation Notes for Claude Code + +1. **Time zones**: store everything in UTC in the database. The frontend always sends ISO-8601 with offset. The backend's stats helpers accept a `tz` query param (IANA tz name, e.g. `Europe/Stockholm`) so day boundaries are computed in local time. Default to `UTC` if omitted. + +2. **Active session guard**: the `POST /api/sessions` endpoint must check for an existing open session in a single atomic DB query (not two round-trips) to avoid race conditions. + +3. **SQLModel async**: use `AsyncSession` throughout. Never mix sync and async SQLAlchemy calls. + +4. **TanStack Query**: `useSleepSession` hook polls `GET /api/sessions/active` every 30 seconds so the status banner stays accurate if the app is open in multiple tabs. + +5. **Recharts responsiveness**: wrap all charts in `` so they adapt to mobile widths. + +6. **Playwright `webServer`**: configure `playwright.config.ts` to start both Vite dev server and backend automatically before running tests, then tear them down after. Use `process.env.CI` to skip auto-start in CI pipelines where servers are started externally. + +7. **Error boundaries**: wrap each chart/stat card in a React error boundary so one failing component does not blank the entire Stats tab. + +8. **Settings persistence**: all user-configurable thresholds live in a React context backed by `localStorage`. Pass them as query params to the stats API so the backend performs the splits server-side (avoids sending raw session data to the client for large intervals). + +9. **ARM Docker builds**: if building on an x86 machine, use `docker buildx` with QEMU emulation: `docker buildx build --platform linux/arm64 .`. Document this in the README. + +10. **Slim images**: do not install dev dependencies in the production images. Backend uses the multi-stage pattern above; frontend discards all `node_modules` after `npm run build`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..195f2c1 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.14-slim AS base +WORKDIR /app + +FROM base AS builder +# Copy pyproject.toml first to leverage Docker cache +COPY pyproject.toml . +# We can use pip to install the project in editable mode or just dependencies +RUN pip install --no-cache-dir . + +FROM base +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin +COPY app/ ./app/ +RUN mkdir -p /app/data +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..9b8bf9d --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,13 @@ +import os +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "sqlite+aiosqlite:///./data/sleep.db" + CORS_ORIGINS: str = "http://localhost:5173" + LOG_LEVEL: str = "info" + + model_config = {"env_file": ".env", "extra": "ignore"} + + +settings = Settings() diff --git a/backend/app/crud.py b/backend/app/crud.py new file mode 100644 index 0000000..9d55648 --- /dev/null +++ b/backend/app/crud.py @@ -0,0 +1,113 @@ +from datetime import datetime, timezone +from typing import List, Optional, Union +from sqlmodel import select, func +from sqlmodel.ext.asyncio.session import AsyncSession +from app.models import SleepSession + +def ensure_aware(dt: datetime) -> datetime: + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + +async def get_sessions( + session: AsyncSession, + from_date: Optional[datetime] = None, + to_date: Optional[datetime] = None, + limit: int = 200, +) -> tuple[List[SleepSession], int]: + if to_date and to_date.hour == 0 and to_date.minute == 0 and to_date.second == 0 and to_date.microsecond == 0: + to_date = to_date.replace(hour=23, minute=59, second=59, microsecond=999999) + + # If we have an aware to_date, but the DB is naive, + # we should make it naive for comparison to avoid errors in some drivers. + query_to_date = to_date.replace(tzinfo=None) if to_date and to_date.tzinfo else to_date + query_from_date = from_date.replace(tzinfo=None) if from_date and from_date.tzinfo else from_date + + count_statement = select(func.count()).select_from(SleepSession) + if query_from_date: + count_statement = count_statement.where(SleepSession.start_time >= query_from_date) + if query_to_date: + count_statement = count_statement.where(SleepSession.start_time <= query_to_date) + count_result = await session.exec(count_statement) + total = count_result.one() or 0 + + statement = select(SleepSession).order_by(SleepSession.start_time.desc()) + if query_from_date: + statement = statement.where(SleepSession.start_time >= query_from_date) + if query_to_date: + statement = statement.where(SleepSession.start_time <= query_to_date) + statement = statement.limit(limit) + + result = await session.exec(statement) + sessions = result.all() + return list(sessions), total + +async def get_active_session(session: AsyncSession) -> Optional[SleepSession]: + statement = select(SleepSession).where(SleepSession.end_time == None) + result = await session.exec(statement) + return result.first() + +async def create_sleep_session(session: AsyncSession, start_time: datetime) -> SleepSession: + new_session = SleepSession(start_time=start_time) + session.add(new_session) + await session.commit() + await session.refresh(new_session) + return new_session + +async def end_sleep_session( + session: AsyncSession, session_id: int, end_time: datetime +) -> Union[SleepSession, bool, str, None]: + statement = select(SleepSession).where(SleepSession.id == session_id) + result = await session.exec(statement) + sleep_session = result.first() + + if not sleep_session: + return None # Not found + + if sleep_session.end_time is not None: + return False # Already ended + + # Normalize to aware for comparison + end_time_aware = end_time.replace(tzinfo=timezone.utc) if end_time.tzinfo is None else end_time + start_time_aware = sleep_session.start_time.replace(tzinfo=timezone.utc) if sleep_session.start_time.tzinfo is None else sleep_session.start_time + + if end_time_aware < start_time_aware: + return "invalid" # End time before start time + + # To store back to naive DB, we strip tzinfo if it was aware + sleep_session.end_time = end_time.replace(tzinfo=None) if end_time.tzinfo else end_time + session.add(sleep_session) + await session.commit() + await session.refresh(sleep_session) + return sleep_session + +async def delete_session(session: AsyncSession, session_id: int) -> bool: + statement = select(SleepSession).where(SleepSession.id == session_id) + result = await session.exec(statement) + sleep_session = result.first() + + if not sleep_session: + return False + + await session.delete(sleep_session) + await session.commit() + return True + +async def update_session( + session: AsyncSession, session_id: int, update_data: dict +) -> Optional[SleepSession]: + statement = select(SleepSession).where(SleepSession.id == session_id) + result = await session.exec(statement) + sleep_session = result.first() + + if not sleep_session: + return None + + for key, value in update_data.items(): + if hasattr(sleep_session, key): + setattr(sleep_session, key, value) + + session.add(sleep_session) + await session.commit() + await session.refresh(sleep_session) + return sleep_session diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..f354c40 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,23 @@ +from sqlmodel import SQLModel, Field, create_engine, Session +from sqlmodel.ext.asyncio.session import AsyncSession +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.orm import sessionmaker +from datetime import datetime +import datetime as dt + +from app.config import settings + +DATABASE_URL = settings.DATABASE_URL + +engine = create_async_engine(DATABASE_URL, echo=False) + + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + +async def get_session() -> AsyncSession: + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with async_session() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..c816b64 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,28 @@ +from fastapi import FastAPI +from contextlib import asynccontextmanager +from fastapi.middleware.cors import CORSMiddleware +from app.config import settings +from app.database import engine, init_db +from app.routers import sessions, stats + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + + +app = FastAPI(lifespan=lifespan) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS.split(","), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(sessions.router) +app.include_router(stats.router) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..068c6d3 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,14 @@ +from datetime import datetime, timezone +from typing import Optional +from sqlmodel import SQLModel, Field + + +class SleepSession(SQLModel, table=True): + __tablename__ = "sleep_session" + + id: Optional[int] = Field(default=None, primary_key=True) + start_time: datetime = Field(nullable=False) + end_time: Optional[datetime] = Field(default=None, nullable=True) + notes: Optional[str] = Field(default=None) + created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/app/routers/sessions.py b/backend/app/routers/sessions.py new file mode 100644 index 0000000..e1042d0 --- /dev/null +++ b/backend/app/routers/sessions.py @@ -0,0 +1,103 @@ +from datetime import datetime, time, timedelta +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel.ext.asyncio.session import AsyncSession +from app.database import get_session +from app.crud import get_sessions, end_sleep_session +from app.schemas import ( + SessionListResponse, + SleepSessionUpdate, + SleepSessionRead, + SleepSessionCreate, +) + + +router = APIRouter(prefix="/api/sessions", tags=["sessions"]) + + +@router.post("", response_model=SleepSessionRead, status_code=status.HTTP_201_CREATED) +async def create_session( + payload: SleepSessionCreate, session: AsyncSession = Depends(get_session) +): + from app.crud import get_active_session, create_sleep_session + + active = await get_active_session(session) + if active: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="An active sleep session is already open.", + ) + + return await create_sleep_session(session, payload.start_time) + + +@router.get("/active") +async def read_active_session(session: AsyncSession = Depends(get_session)): + from app.crud import get_active_session + + active = await get_active_session(session) + return {"session": active} + + +@router.get("", response_model=SessionListResponse) +async def list_sessions( + from_date: Optional[datetime] = None, + to_date: Optional[datetime] = None, + limit: int = 200, + session: AsyncSession = Depends(get_session), +): + sessions, total = await get_sessions(session, from_date, to_date, limit) + return {"sessions": sessions, "total": total} + + +@router.patch("/{session_id}/end", response_model=None) +async def end_session_endpoint( + session_id: int, + payload: SleepSessionUpdate, + session: AsyncSession = Depends(get_session), +): + if not payload.end_time: + raise HTTPException( + status_code=400, detail="end_time is required to end a session." + ) + + result = await end_sleep_session(session, session_id, payload.end_time) + + if result is None: + raise HTTPException(status_code=404, detail="Session not found.") + if result is False: + raise HTTPException(status_code=409, detail="Session has already ended.") + if result == "invalid": + raise HTTPException( + status_code=400, detail="end_time cannot be before start_time." + ) + + return result + + +@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_session_endpoint( + session_id: int, session: AsyncSession = Depends(get_session) +): + from app.crud import delete_session + + success = await delete_session(session, session_id) + if not success: + raise HTTPException(status_code=404, detail="Session not found.") + return None + + +@router.put("/{session_id}", response_model=None) +async def update_session_endpoint( + session_id: int, + payload: SleepSessionUpdate, + session: AsyncSession = Depends(get_session), +): + from app.crud import update_session + + updated = await update_session( + session, session_id, payload.model_dump(exclude_unset=True) + ) + if not updated: + raise HTTPException(status_code=404, detail="Session not found.") + return updated diff --git a/backend/app/routers/stats.py b/backend/app/routers/stats.py new file mode 100644 index 0000000..b77905b --- /dev/null +++ b/backend/app/routers/stats.py @@ -0,0 +1,159 @@ +from datetime import datetime, time, timedelta +from typing import List, Optional +from fastapi import APIRouter, Depends, Query +from sqlmodel.ext.asyncio.session import AsyncSession +from app.database import get_session +from app.crud import get_sessions +from app.schemas import DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse +from app.utils.time_utils import get_day_night_splits + +router = APIRouter(prefix="/api/stats", tags=["stats"]) + + +@router.get("/daily-summary", response_model=DailySummaryResponse) +async def get_daily_summary( + from_date: datetime, + to_date: datetime, + day_start: str = Query("07:00"), + day_end: str = Query("20:00"), + session: AsyncSession = Depends(get_session), +): + try: + day_start_time = time.fromisoformat(day_start) + day_end_time = time.fromisoformat(day_end) + except ValueError: + return {"days": []} + + sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + summary_map = {} + + for s in sessions: + if not s.end_time: + continue + segments = get_day_night_splits( + s.start_time, s.end_time, day_start_time, day_end_time + ) + for seg in segments: + date_str = seg["start"].strftime("%Y-%m-%d") + if date_str not in summary_map: + summary_map[date_str] = {"day": 0.0, "night": 0.0} + duration = (seg["end"] - seg["start"]).total_seconds() / 60.0 + if seg["type"] == "day": + summary_map[date_str]["day"] += duration + else: + summary_map[date_str]["night"] += duration + + days = [] + for date_str in sorted(summary_map.keys()): + data = summary_map[date_str] + days.append( + { + "date": date_str, + "total_minutes": int(round(data["day"] + data["night"])), + "day_minutes": int(round(data["day"])), + "night_minutes": int(round(data["night"])), + } + ) + return {"days": days} + + +@router.get("/timetable", response_model=TimetableResponse) +async def get_timetable( + from_date: datetime, + to_date: datetime, + day_start: str = Query("07:00"), + day_end: str = Query("20:00"), + session: AsyncSession = Depends(get_session), +): + sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + slots = [] + current_date = from_date.date() + while current_date <= to_date.date(): + date_str = current_date.strftime("%Y-%m-%d") + for i in range(96): + slot_start_time = datetime.combine(current_date, time(0, 0)) + timedelta( + minutes=i * 15 + ) + slot_end_time = slot_start_time + timedelta(minutes=15) + slot_start_str = slot_start_time.strftime("%H:%M") + slot_end_str = slot_end_time.strftime("%H:%M") + is_sleeping = False + for s in sessions: + if s.start_time <= slot_end_time and ( + s.end_time is None or s.end_time >= slot_start_time + ): + is_sleeping = True + break + slots.append( + { + "date": date_str, + "slot_start": slot_start_str, + "slot_end": slot_end_str, + "sleeping": is_sleeping, + } + ) + current_date += timedelta(days=1) + return {"slots": slots} + + +@router.get("/wake-sleep-times", response_model=WakeupBedtimeResponse) +async def get_wake_sleep_times( + from_date: datetime, + to_date: datetime, + wakeup_ref: str = Query("04:00"), + bedtime_ref: str = Query("19:00"), + session: AsyncSession = Depends(get_session), +): + try: + w_ref_time = time.fromisoformat(wakeup_ref) + b_ref_time = time.fromisoformat(bedtime_ref) + except ValueError: + return {"days": []} + + sessions, _ = await get_sessions(session, from_date=from_date, to_date=to_date) + days_data = [] + current_date = from_date.date() + while current_date <= to_date.date(): + date_str = current_date.strftime("%Y-%m-%d") + day_sessions = [ + s + for s in sessions + if s.start_time.date() == current_date + or (s.end_time and s.end_time.date() == current_date) + ] + + wakeup_min = None + bedtime_min = None + + if day_sessions: + # Find first wakeup after wakeup_ref + wakeups = [] + for s in day_sessions: + if s.end_time and s.end_time.time() >= w_ref_time: + diff = ( + s.end_time - datetime.combine(current_date, w_ref_time) + ).total_seconds() / 60.0 + wakeups.append(int(diff)) + if wakeups: + wakeup_min = min(wakeups) + + # Find last bedtime before bedtime_ref + bedtimes = [] + for s in day_sessions: + if s.start_time.time() <= b_ref_time: + diff = ( + s.start_time - datetime.combine(current_date, b_ref_time) + ).total_seconds() / 60.0 + bedtimes.append(int(diff)) + if bedtimes: + bedtime_min = max(bedtimes) + + days_data.append( + { + "date": date_str, + "wakeup_minutes": wakeup_min, + "bedtime_minutes": bedtime_min, + } + ) + current_date += timedelta(days=1) + return {"days": days_data} diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..f3bacc1 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,59 @@ +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel + + +class SleepSessionCreate(BaseModel): + start_time: datetime + + +class SleepSessionUpdate(BaseModel): + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + notes: Optional[str] = None + + +class SleepSessionRead(BaseModel): + id: int + start_time: datetime + end_time: Optional[datetime] + notes: Optional[str] + created_at: datetime + updated_at: datetime + + +class SessionListResponse(BaseModel): + sessions: List[SleepSessionRead] + total: int + + +class DailySummaryDay(BaseModel): + date: str + total_minutes: int + day_minutes: int + night_minutes: int + + +class DailySummaryResponse(BaseModel): + days: List[DailySummaryDay] + + +class TimetableSlot(BaseModel): + date: str + slot_start: str + slot_end: str + sleeping: bool + + +class TimetableResponse(BaseModel): + slots: List[TimetableSlot] + + +class WakeupBedtimeDay(BaseModel): + date: str + wakeup_minutes: Optional[int] + bedtime_minutes: Optional[int] + + +class WakeupBedtimeResponse(BaseModel): + days: List[WakeupBedtimeDay] diff --git a/backend/app/utils/time_utils.py b/backend/app/utils/time_utils.py new file mode 100644 index 0000000..49cad86 --- /dev/null +++ b/backend/app/utils/time_utils.py @@ -0,0 +1,64 @@ +from datetime import datetime, time, timedelta +from typing import List + + +def get_day_night_splits( + session_start: datetime, + session_end: datetime, + day_start_boundary: time, + day_end_boundary: time, +) -> List[dict]: + """ + Splits a sleep session into day and night segments based on boundaries. + A segment is defined by [segment_start, segment_end] and its type (day/night). + """ + segments = [] + points = [session_start, session_end] + + for offset in [0, 1]: + base_date = session_start.date() + timedelta(days=offset) + try: + b1 = datetime.combine(base_date, day_start_boundary).replace( + tzinfo=session_start.tzinfo + ) + b2 = datetime.combine(base_date, day_end_boundary).replace( + tzinfo=session_start.tzinfo + ) + if session_start < b1 < session_end: + points.append(b1) + if session_start < b2 < session_end: + points.append(b2) + except (ValueError, AttributeError): + continue + + sorted_points = sorted(list(set(points))) + + for i in range(len(sorted_points) - 1): + seg_start = sorted_points[i] + seg_end = sorted_points[i + 1] + midpoint = seg_start + (seg_end - seg_start) / 2 + is_day = day_start_pattern( + midpoint.time(), day_start_boundary, day_end_boundary + ) + segments.append( + {"start": seg_start, "end": seg_end, "type": "day" if is_day else "night"} + ) + + return segments + + +def day_start_pattern(current_time: time, start: time, end: time) -> bool: + if start <= end: + return start <= current_time < end + else: # Spans midnight + return current_time >= start or current_time < end + + +from datetime import datetime, time, timedelta + + +def minutes_after(dt: datetime, reference: time) -> float: + """Returns minutes after a reference time.""" + ref_delta = timedelta(hours=reference.hour, minutes=reference.minute) + dt_delta = timedelta(hours=dt.hour, minutes=dt.minute) + return (dt_delta - ref_delta).total_seconds() / 60.0 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..874e0c6 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "baby-sleep-tracker-backend" +version = "0.1.0" +description = "FastAPI backend for Baby Sleep Tracker" +readme = "../README.md" +requires-python = ">=3.14" +dependencies = [ + "fastapi", + "uvicorn", + "sqlmodel", + "aiosqlite", + "sqlalchemy", + "python-multipart", + "pydantic", + "pydantic-settings", + "greenlet", +] + +[dependency-groups] +dev = [ + "pytest", + "httpx", + "pytest-asyncio", +] + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3e64b27 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,47 @@ +import pytest +import pytest_asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlmodel import SQLModel +from sqlalchemy.orm import sessionmaker +from httpx import AsyncClient, ASGITransport +from app.main import app + +@pytest_asyncio.fixture(scope="session") +async def engine(): + """Create a single in-memory SQLite engine for the entire test session.""" + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + echo=False, + ) + # Create tables once for the whole session to avoid overhead + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + yield engine + await engine.dispose() + +@pytest_asyncio.fixture +async def session_factory(engine): + """Create async session factory.""" + return sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +@pytest_asyncio.fixture +async def db_session(session_factory): + """Create a database session for a test. Rolls back after each test.""" + async with session_factory() as session: + yield session + await session.rollback() + +@pytest.fixture +def sample_session_data(): + """Sample sleep session data for testing.""" + return { + "start_time": "2024-01-15T21:30:00Z", + } + +@pytest.fixture +async def api_client(engine): + """Provides an AsyncClient for testing API endpoints.""" + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + yield client diff --git a/backend/tests/test_bug_repro.py b/backend/tests/test_bug_repro.py new file mode 100644 index 0000000..89a5f00 --- /dev/null +++ b/backend/tests/test_bug_repro.py @@ -0,0 +1,69 @@ +import pytest +from datetime import datetime, timedelta, timezone +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.database import engine +from app.models import SQLModel + +@pytest.fixture +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + +@pytest.mark.asyncio +async def test_list_sessions_date_only_filter(setup_db): + # Test that passing a date (midnight) also works and includes the whole day + target_date = datetime(2023, 10, 1, tzinfo=timezone.utc) + other_date = datetime(2023, 10, 2, tzinfo=timezone.utc) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # 1. Create a session that starts LATER in the day on OTHER date (should be excluded) + # We end it immediately to avoid 409s in the test + start_time_other = other_date.replace(hour=20, minute=0) + resp_other = await ac.post("/api/sessions", json={"start_time": start_time_other.isoformat()}) + assert resp_other.status_code == 201 + session_id_other = resp_other.json()["id"] + await ac.patch(f"/api/sessions/{session_id_other}/end", json={"end_time": (start_time_other + timedelta(minutes=30)).isoformat()}) + + # 2. Create a session that starts ON the target_date (should be included) + start_time_target = target_date.replace(hour=20, minute=0) + resp_target = await ac.post("/api/sessions", json={"start_time": start_time_target.isoformat()}) + assert resp_target.status_code == 201 + session_id_target = resp_target.json()["id"] + await ac.patch(f"/api/sessions/{session_id_target}/end", json={"end_time": (start_time_target + timedelta(minutes=30)).isoformat()}) + + # 3. Create a session that starts EARLIER on the target_date (should be included) + start_time_early = target_date.replace(hour=2, minute=0) + resp_early = await ac.post("/api/sessions", json={"start_time": start_time_early.isoformat()}) + assert resp_early.status_code == 201 + session_id_early = resp_early.json()["id"] + await ac.patch(f"/api/sessions/{session_id_early}/end", json={"end_time": (start_time_early + timedelta(minutes=30)).isoformat()}) + + # 4. Create a session that is on the OTHER date (should be excluded) + start_time_other2 = other_date.replace(hour=2, minute=0) + resp_other2 = await ac.post("/api/sessions", json={"start_time": start_time_other2.isoformat()}) + assert resp_other2.status_code == 201 + session_id_other2 = resp_other2.json()["id"] + await ac.patch(f"/api/sessions/{session_id_other2}/end", json={"end_time": (start_time_other2 + timedelta(minutes=30)).isoformat()}) + + # Query with to_date = target_date (midnight) + # This should be expanded to 23:59:59 on target_date. + # Should include the 2 sessions on target_date (target and early). + # Should exclude the 2 sessions on other_date. + to_date_str = target_date.strftime("%Y-%m-%d") # "2023-10-01" + print(f"\nGET: to_date={to_date_str}") + + response = await ac.get(f"/api/sessions?to_date={to_date_str}") + print(f"GET RESPONSE: {response.status_code}, body={response.json()}") + + assert response.status_code == 200 + sessions = response.json()["sessions"] + + # We expect exactly 2 sessions (the ones on target_date) + assert len(sessions) == 2, f"Expected 2 sessions (the ones on {to_date_str}), but got {len(sessions)}. (Body: {response.json()})" + for s in sessions: + # Verify that the sessions returned are indeed from the target_date + assert target_date.strftime("%Y-%m-%d") in s["start_time"] diff --git a/backend/tests/test_sessions.py b/backend/tests/test_sessions.py new file mode 100644 index 0000000..0ba1fa9 --- /dev/null +++ b/backend/tests/test_sessions.py @@ -0,0 +1,111 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.database import engine, init_db +from app.models import SQLModel +from datetime import datetime, timezone, timedelta + + +@pytest.fixture +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + + +@pytest.mark.asyncio +async def test_create_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + payload = {"start_time": start_time} + response = await ac.post("/api/sessions", json=payload) + assert response.status_code == 201 + data = response.json() + assert datetime.fromisoformat(data["start_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(start_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_create_session_already_open(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.post("/api/sessions", json={"start_time": start_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_end_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + + end_time = datetime.now(timezone.utc).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + + assert response.status_code == 200 + assert datetime.fromisoformat(response.json()["end_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(end_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_list_sessions(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.get("/api/sessions") + assert response.status_code == 200 + assert len(response.json()["sessions"]) >= 1 + + +@pytest.mark.asyncio +async def test_end_session_before_start_time(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + end_time = ( + datetime.now(timezone.utc).replace(hour=0, minute=0) - timedelta(hours=1) + ).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + assert response.status_code == 400 + + +@pytest.mark.asyncio +async def test_end_already_ended_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + end_time = datetime.now(timezone.utc).isoformat() + await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_delete_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.delete("/api/sessions/1") + assert response.status_code == 204 + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac2: + list_resp = await ac2.get("/api/sessions") + assert not any(s["id"] == 1 for s in list_resp.json()["sessions"]) diff --git a/backend/tests/test_sessions_fixed.py b/backend/tests/test_sessions_fixed.py new file mode 100644 index 0000000..17fa040 --- /dev/null +++ b/backend/tests/test_sessions_fixed.py @@ -0,0 +1,68 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.database import engine, init_db +from app.models import SQLModel +from datetime import datetime, timezone + + +@pytest.fixture +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + + +@pytest.mark.asyncio +async def test_create_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + payload = {"start_time": start_time} + response = await ac.post("/api/sessions", json=payload) + assert response.status_code == 201 + data = response.json() + assert datetime.fromisoformat(data["start_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(start_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_create_session_already_open(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.post("/api/sessions", json={"start_time": start_time}) + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_end_session(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + + end_time = datetime.now(timezone.utc).isoformat() + response = await ac.patch("/api/sessions/1/end", json={"end_time": end_time}) + + assert response.status_code == 200 + assert datetime.fromisoformat(response.json()["end_time"]).replace( + tzinfo=None + ) == datetime.fromisoformat(end_time).replace(tzinfo=None) + + +@pytest.mark.asyncio +async def test_list_sessions(setup_db): + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + start_time = datetime.now(timezone.utc).isoformat() + await ac.post("/api/sessions", json={"start_time": start_time}) + response = await ac.get("/api/sessions") + assert response.status_code == 200 + assert len(response.json()["sessions"]) >= 1 diff --git a/backend/tests/test_stats.py b/backend/tests/test_stats.py new file mode 100644 index 0000000..39d57ae --- /dev/null +++ b/backend/tests/test_stats.py @@ -0,0 +1,73 @@ +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app +from app.database import engine, init_db +from app.models import SQLModel +from datetime import datetime, timezone, timedelta +from sqlmodel.ext.asyncio.session import AsyncSession + + +@pytest.fixture +async def setup_db(): + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.drop_all) + await conn.run_sync(SQLModel.metadata.create_all) + + +@pytest.mark.asyncio +async def test_daily_summary(setup_db): + from datetime import date + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + # Create a session spanning today + start = datetime.now(timezone.utc) - timedelta(hours=5) + end = datetime.now(timezone.utc) + # We need to manually insert since the API only allows POST for start_time + from app.database import get_session + from app.models import SleepSession + from sqlalchemy import select + + async with AsyncSession(engine) as session: + new_s = SleepSession(start_time=start, end_time=end) + session.add(new_s) + await session.commit() + + from_date = date.today().isoformat() + # Use the next day as to_date to cover the whole current day + to_date = (date.today() + timedelta(days=1)).isoformat() + response = await ac.get( + f"/api/stats/daily-summary?from_date={from_date}&to_date={to_date}" + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["days"]) >= 1 + + +@pytest.mark.asyncio +async def test_timetable(setup_db): + from datetime import date + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as ac: + from app.database import get_session + from app.models import SleepSession + from sqlalchemy import select + + async with AsyncSession(engine) as session: + start = datetime.now(timezone.utc) - timedelta(hours=1) + new_s = SleepSession(start_time=start, end_time=datetime.now(timezone.utc)) + session.add(new_s) + await session.commit() + + from_date = date.today().isoformat() + to_date = (date.today() + timedelta(days=1)).isoformat() + response = await ac.get( + f"/api/stats/timetable?from_date={from_date}&to_date={to_date}" + ) + + assert response.status_code == 200 + assert len(response.json()["slots"]) > 0 diff --git a/backend/tests/test_time_utils.py b/backend/tests/test_time_utils.py new file mode 100644 index 0000000..4e22b38 --- /dev/null +++ b/backend/tests/test_time_utils.py @@ -0,0 +1,121 @@ +import pytest +from datetime import datetime, time, timedelta +from app.utils.time_utils import get_day_night_splits, minutes_after, day_start_pattern + +class TestGetDayNightSplits: + def test_zero_length_session(self): + start = datetime(2024, 1, 15, 10, 0) + end = datetime(2024, 1, 15, 10, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 0 + + def test_session_entirely_in_day(self): + start = datetime(2024, 1, 15, 10, 0) + end = datetime(2024, 1, 15, 14, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 1 + assert segments[0]["type"] == "day" + + def test_session_entirely_in_night(self): + start = datetime(2024, 1, 15, 22, 0) + end = datetime(2024, 1, 16, 2, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 1 + assert segments[0]["type"] == "night" + + def test_session_spans_day_night_boundary(self): + start = datetime(2024, 1, 15, 18, 0) + end = datetime(2024, 1, 15, 22, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 2 + assert segments[0]["type"] == "day" + assert segments[1]["type"] == "night" + + def test_session_spans_midnight(self): + start = datetime(2024, 1, 15, 23, 0) + end = datetime(2024, 1, 16, 2, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 1 + assert segments[0]["type"] == "night" + + def test_session_spans_two_days(self): + # Start Day 1 at 23:00, End Day 2 at 01:00 (Night) + # It should catch the transition if day_start/end logic is correct. + # But here, no boundary (07:00 or 20:00) is in the session. + # So it should just be one segment of type 'night'. + start = datetime(2024, 1, 15, 23, 0) + end = datetime(2024, 1, 16, 1, 0) + day_start = time(7, 0) + segments = get_day_night_splits(start, end, day_start, time(20, 0)) + assert len(segments) == 1 + assert segments[0]["type"] == "night" + + def test_session_boundary_at_start(self): + # Session starts exactly at day_start + start = datetime(2024, 1, 15, 7, 0) + end = datetime(2024, 1, 15, 10, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + # The loop uses: if session_start < b1 < session_end + # Since 7:00 is not > 7:00, b1 won't be added. + # So it should result in 1 segment [start, end] + assert len(segments) == 1 + assert segments[0]["type"] == "day" + + def test_session_boundary_at_end(self): + # Session ends exactly at day_end + start = datetime(2024, 1, 15, 18, 0) + end = datetime(2024, 1, 15, 20, 0) + day_start = time(7, 0) + day_end = time(20, 0) + segments = get_day_night_splits(start, end, day_start, day_end) + assert len(segments) == 1 + assert segments[0]["type"] == "day" + +class TestMinutesAfter: + def test_minutes_at_reference(self): + dt = datetime(2024, 1, 15, 4, 0) + ref = time(4, 0) + result = minutes_after(dt, ref) + assert result == 0 + + def test_minutes_after_reference(self): + dt = datetime(2024, 1, 15, 7, 30) + ref = time(4, 0) + result = minutes_after(dt, ref) + assert result == 210 + + def test_before_reference(self): + dt = datetime(2024, 1, 15, 3, 30) + ref = time(4, 0) + result = minutes_after(dt, ref) + assert result == -30 + +class TestDayStartPattern: + def test_within_day_range(self): + assert day_start_pattern(time(12, 0), time(7, 0), time(20, 0)) is True + + def test_outside_day_range(self): + assert day_start_pattern(time(3, 0), time(7, 0), time(20, 0)) is False + + def test_at_boundary(self): + assert day_start_pattern(time(7, 0), time(7, 0), time(20, 0)) is True + + def test_just_after_boundary(self): + # Test that the end boundary is exclusive + assert day_start_pattern(time(20, 0), time(7, 0), time(20, 0)) is False + + def test_spans_midnight(self): + assert day_start_pattern(time(22, 0), time(20, 0), time(7, 0)) is True + assert day_start_pattern(time(3, 0), time(20, 0), time(7, 0)) is True diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..44064c5 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,442 @@ +version = 1 +revision = 3 +requires-python = ">=3.14" + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, +] + +[[package]] +name = "baby-sleep-tracker-backend" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosqlite" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "sqlmodel" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite" }, + { name = "fastapi" }, + { name = "greenlet" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "sqlmodel" }, + { name = "uvicorn" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f7/e6/7adb4c5fa231e82c35b8f5741a9f2d055f520c29af5546fd70d3e8e1cd2e/fastapi-0.135.3.tar.gz", hash = "sha256:bd6d7caf1a2bdd8d676843cdcd2287729572a1ef524fc4d65c17ae002a1be654", size = 396524, upload-time = "2026-04-01T16:23:58.188Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/a4/5caa2de7f917a04ada20018eccf60d6cc6145b0199d55ca3711b0fc08312/fastapi-0.135.3-py3-none-any.whl", hash = "sha256:9b0f590c813acd13d0ab43dd8494138eb58e484bfac405db1f3187cfc5810d98", size = 117734, upload-time = "2026-04-01T16:23:59.328Z" }, +] + +[[package]] +name = "greenlet" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" }, + { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" }, + { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" }, + { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" }, + { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" }, + { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" }, + { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" }, + { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" }, + { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" }, + { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/6b/1353beb3d1cd5cf61cdec5b6f87a9872399de3bc5cae0b7ce07ff4de2ab0/pydantic-2.13.1.tar.gz", hash = "sha256:a0f829b279ddd1e39291133fe2539d2aa46cc6b150c1706a270ff0879e3774d2", size = 843746, upload-time = "2026-04-15T14:57:19.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/5a/2225f4c176dbfed0d809e848b50ef08f70e61daa667b7fa14b0d311ae44d/pydantic-2.13.1-py3-none-any.whl", hash = "sha256:9557ecc2806faaf6037f85b1fbd963d01e30511c48085f0d573650fdeaad378a", size = 471917, upload-time = "2026-04-15T14:57:17.277Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/93/f97a86a7eb28faa1d038af2fd5d6166418b4433659108a4c311b57128b2d/pydantic_core-2.46.1.tar.gz", hash = "sha256:d408153772d9f298098fb5d620f045bdf0f017af0d5cb6e309ef8c205540caa4", size = 471230, upload-time = "2026-04-15T14:49:34.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/2b/6793fe89ab66cb2d3d6e5768044eab80bba1d0fae8fd904d0a1574712e17/pydantic_core-2.46.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9917cb61effac7ec0f448ef491ec7584526d2193be84ff981e85cbf18b68c42a", size = 2118110, upload-time = "2026-04-15T14:50:52.947Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/e9a905ddfcc2fd7bd862b340c02be6ab1f827922822d425513635d0ac774/pydantic_core-2.46.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e749679ca9f8a9d0bff95fb7f6b57bb53f2207fa42ffcc1ec86de7e0029ab89", size = 1948645, upload-time = "2026-04-15T14:51:55.577Z" }, + { url = "https://files.pythonhosted.org/packages/15/23/26e67f86ed62ac9d6f7f3091ee5220bf14b5ac36fb811851d601365ef896/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2ecacee70941e233a2dad23f7796a06f86cc10cc2fbd1c97c7dd5b5a79ffa4f", size = 1977576, upload-time = "2026-04-15T14:49:37.58Z" }, + { url = "https://files.pythonhosted.org/packages/b8/78/813c13c0de323d4de54ee2e6fdd69a0271c09ac8dd65a8a000931aa487a5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:647d0a2475b8ed471962eed92fa69145b864942f9c6daa10f95ac70676637ae7", size = 2060358, upload-time = "2026-04-15T14:51:40.087Z" }, + { url = "https://files.pythonhosted.org/packages/09/5e/4caf2a15149271fbd2b4d968899a450853c800b85152abcf54b11531417f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac9cde61965b0697fce6e6cc372df9e1ad93734828aac36e9c1c42a22ad02897", size = 2235980, upload-time = "2026-04-15T14:50:34.535Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c1/a2cdabb5da6f5cb63a3558bcafffc20f790fa14ccffbefbfb1370fadc93f/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0a2eb0864085f8b641fb3f54a2fb35c58aff24b175b80bc8a945050fcde03204", size = 2316800, upload-time = "2026-04-15T14:52:46.999Z" }, + { url = "https://files.pythonhosted.org/packages/76/fd/19d711e4e9331f9d77f222bffc202bf30ea0d74f6419046376bb82f244c8/pydantic_core-2.46.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b83ce9fede4bc4fb649281d9857f06d30198b8f70168f18b987518d713111572", size = 2101762, upload-time = "2026-04-15T14:49:24.278Z" }, + { url = "https://files.pythonhosted.org/packages/dc/64/ce95625448e1a4e219390a2923fd594f3fa368599c6b42ac71a5df7238c9/pydantic_core-2.46.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:cb33192753c60f269d2f4a1db8253c95b0df6e04f2989631a8cc1b0f4f6e2e92", size = 2167737, upload-time = "2026-04-15T14:50:41.637Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/413572d03ca3e73b408f00f54418b91a8be6401451bc791eaeff210328e5/pydantic_core-2.46.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96611d51f953f87e1ae97637c01ee596a08b7f494ea00a5afb67ea6547b9f53b", size = 2185658, upload-time = "2026-04-15T14:51:46.799Z" }, + { url = "https://files.pythonhosted.org/packages/36/09/e4f581353bdf3f0c7de8a8b27afd14fc761da29d78146376315a6fedc487/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:9b176fa55f9107db5e6c86099aa5bfd934f1d3ba6a8b43f714ddeebaed3f42b7", size = 2184154, upload-time = "2026-04-15T14:52:49.629Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a4/d0d52849933f5a4bf1ad9d8da612792f96469b37e286a269e3ee9c60bbb1/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:79a59f63a4ce4f3330e27e6f3ce281dd1099453b637350e97d7cf24c207cd120", size = 2332379, upload-time = "2026-04-15T14:49:55.009Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/25bfb08fdbef419f73290e573899ce938a327628c34e8f3a4bafeea30126/pydantic_core-2.46.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:f200fce071808a385a314b7343f5e3688d7c45746be3d64dc71ee2d3e2a13268", size = 2377964, upload-time = "2026-04-15T14:51:59.649Z" }, + { url = "https://files.pythonhosted.org/packages/15/36/b777766ff83fef1cf97473d64764cd44f38e0d8c269ed06faace9ae17666/pydantic_core-2.46.1-cp314-cp314-win32.whl", hash = "sha256:3a07eccc0559fb9acc26d55b16bf8ebecd7f237c74a9e2c5741367db4e6d8aff", size = 1976450, upload-time = "2026-04-15T14:51:57.665Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4b/4cd19d2437acfc18ca166db5a2067040334991eb862c4ecf2db098c91fbf/pydantic_core-2.46.1-cp314-cp314-win_amd64.whl", hash = "sha256:1706d270309ac7d071ffe393988c471363705feb3d009186e55d17786ada9622", size = 2067750, upload-time = "2026-04-15T14:49:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a0/490751c0ef8f5b27aae81731859aed1508e72c1a9b5774c6034269db773b/pydantic_core-2.46.1-cp314-cp314-win_arm64.whl", hash = "sha256:22d4e7457ade8af06528012f382bc994a97cc2ce6e119305a70b3deff1e409d6", size = 2021109, upload-time = "2026-04-15T14:50:27.728Z" }, + { url = "https://files.pythonhosted.org/packages/36/3a/2a018968245fffd25d5f1972714121ad309ff2de19d80019ad93494844f9/pydantic_core-2.46.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:607ff9db0b7e2012e7eef78465e69f9a0d7d1c3e7c6a84cf0c4011db0fcc3feb", size = 2111548, upload-time = "2026-04-15T14:52:08.273Z" }, + { url = "https://files.pythonhosted.org/packages/77/5b/4103b6192213217e874e764e5467d2ff10d8873c1147d01fa432ac281880/pydantic_core-2.46.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8cda3eacaea13bd02a1bea7e457cc9fc30b91c5a91245cef9b215140f80dd78c", size = 1926745, upload-time = "2026-04-15T14:50:03.045Z" }, + { url = "https://files.pythonhosted.org/packages/c3/70/602a667cf4be4bec6c3334512b12ae4ea79ce9bfe41dc51be1fd34434453/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b9493279cdc7997fe19e5ed9b41f30cbc3806bd4722adb402fedb6f6d41bd72a", size = 1965922, upload-time = "2026-04-15T14:51:12.555Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/06a89ce5323e755b7d2812189f9706b87aaebe49b34d247b380502f7992c/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3644e5e10059999202355b6c6616e624909e23773717d8f76deb8a6e2a72328c", size = 2043221, upload-time = "2026-04-15T14:51:18.995Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6e/b1d9ad907d9d76964903903349fd2e33c87db4b993cc44713edcad0fc488/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ad6c9de57683e26c92730991960c0c3571b8053263b042de2d3e105930b2767", size = 2243655, upload-time = "2026-04-15T14:50:10.718Z" }, + { url = "https://files.pythonhosted.org/packages/ef/73/787abfaad51174641abb04c8aa125322279b40ad7ce23c495f5a69f76554/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:557ebaa27c7617e7088002318c679a8ce685fa048523417cd1ca52b7f516d955", size = 2295976, upload-time = "2026-04-15T14:53:09.694Z" }, + { url = "https://files.pythonhosted.org/packages/56/0b/b7c5a631b6d5153d4a1ea4923b139aea256dc3bd99c8e6c7b312c7733146/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cd37e39b22b796ba0298fe81e9421dd7b65f97acfbb0fb19b33ffdda7b9a7b4", size = 2103439, upload-time = "2026-04-15T14:50:08.32Z" }, + { url = "https://files.pythonhosted.org/packages/2a/3f/952ee470df69e5674cdec1cbde22331adf643b5cc2ff79f4292d80146ee4/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:6689443b59714992e67d62505cdd2f952d6cf1c14cc9fd9aeec6719befc6f23b", size = 2132871, upload-time = "2026-04-15T14:50:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8b/1dea3b1e683c60c77a60f710215f90f486755962aa8939dbcb7c0f975ac3/pydantic_core-2.46.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6f32c41ca1e3456b5dd691827b7c1433c12d5f0058cc186afbb3615bc07d97b8", size = 2168658, upload-time = "2026-04-15T14:52:24.897Z" }, + { url = "https://files.pythonhosted.org/packages/67/97/32ae283810910d274d5ba9f48f856f5f2f612410b78b249f302d297816f5/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:88cd1355578852db83954dc36e4f58f299646916da976147c20cf6892ba5dc43", size = 2171184, upload-time = "2026-04-15T14:52:34.854Z" }, + { url = "https://files.pythonhosted.org/packages/a2/57/c9a855527fe56c2072070640221f53095b0b19eaf651f3c77643c9cabbe3/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a170fefdb068279a473cc9d34848b85e61d68bfcc2668415b172c5dfc6f213bf", size = 2316573, upload-time = "2026-04-15T14:52:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/14c39ffc7399819c5448007c7bcb4e6da5669850cfb7dcbb727594290b48/pydantic_core-2.46.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:556a63ff1006934dba4eed7ea31b58274c227e29298ec398e4275eda4b905e95", size = 2378340, upload-time = "2026-04-15T14:51:02.619Z" }, + { url = "https://files.pythonhosted.org/packages/01/55/a37461fbb29c053ea4e62cfc5c2d56425cb5efbef8316e63f6d84ae45718/pydantic_core-2.46.1-cp314-cp314t-win32.whl", hash = "sha256:3b146d8336a995f7d7da6d36e4a779b7e7dff2719ac00a1eb8bd3ded00bec87b", size = 1960843, upload-time = "2026-04-15T14:52:06.103Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/97e1221197d17a27f768363f87ec061519eeeed15bbd315d2e9d1429ff03/pydantic_core-2.46.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f1bc856c958e6fe9ec071e210afe6feb695f2e2e81fd8d2b102f558d364c4c17", size = 2048696, upload-time = "2026-04-15T14:52:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/19/d5/4eac95255c7d35094b46a32ec1e4d80eac94729c694726ee1d69948bd5f0/pydantic_core-2.46.1-cp314-cp314t-win_arm64.whl", hash = "sha256:21a5bfd8a1aa4de60494cdf66b0c912b1495f26a8899896040021fbd6038d989", size = 2022343, upload-time = "2026-04-15T14:49:49.036Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.49" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" }, + { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" }, + { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" }, + { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" }, + { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" }, +] + +[[package]] +name = "sqlmodel" +version = "0.0.38" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" }, +] + +[[package]] +name = "starlette" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, +] diff --git a/backend_log.txt b/backend_log.txt new file mode 100644 index 0000000..2c744b1 --- /dev/null +++ b/backend_log.txt @@ -0,0 +1,14 @@ +INFO: Will watch for changes in these directories: ['/Users/jhag/projects/babymonitor/backend'] +INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit) +INFO: Started reloader process [63655] using StatReload +INFO: Started server process [63657] +INFO: Waiting for application startup. +INFO: Application startup complete. +WARNING: StatReload detected changes in 'app/main.py'. Reloading... +INFO: Shutting down +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [63657] +INFO: Started server process [71064] +INFO: Waiting for application startup. +INFO: Application startup complete. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3f746ed --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + backend: + build: + context: ./backend + platforms: [linux/arm64] + volumes: + - sleep_data:/app/data + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/sleep.db + - CORS_ORIGINS=http://localhost + restart: unless-stopped + + frontend: + build: + context: ./frontend + platforms: [linux/arm64] + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + sleep_data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..4b3d400 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +dist +*.log +.git +.gitignore +Dockerfile +.dockerignore diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..00ad83b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,30 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +# Copy package files first to leverage Docker cache +COPY package*.json ./ + +# Install dependencies (including devDependencies for build) +RUN npm ci + +# Copy the rest of the application source code +COPY . . + +# Build the production assets +RUN npm run build + +# Stage 2: Production stage +FROM nginx:1.27-alpine + +# Copy the built assets from the build stage to Nginx's default HTML directory +COPY --from=build /app/dist /usr/share/nginx/html + +# Copy our custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 to allow access to the web application +EXPOSE 80 + +# Nginx runs in the foreground by default when using its official image +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/frontend_log.txt b/frontend/frontend_log.txt new file mode 100644 index 0000000..70d4c0e --- /dev/null +++ b/frontend/frontend_log.txt @@ -0,0 +1,317 @@ + +> baby-sleep-tracker-frontend@0.1.0 dev +> vite + + + VITE v6.4.2 ready in 104 ms + + ➜ Local: http://localhost:5173/ + ➜ Network: use --host to expose +6:38:23 PM [vite] (client) page reload index.html +6:38:29 PM [vite] Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/main.tsx: Expected corresponding JSX closing tag for . (13:4) + + 11 | + 12 | +> 13 | + | ^ + 14 | , + 15 | ) + 16 | + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/main.tsx:13:4 + 11 | + 12 | + 13 | + | ^ + 14 | , + 15 | ) +6:40:25 PM [vite] Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/main.tsx: Expected corresponding JSX closing tag for . (13:4) + + 11 | + 12 | +> 13 | + | ^ + 14 | , + 15 | ) + 16 | + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/main.tsx:13:4 + 11 | + 12 | + 13 | + | ^ + 14 | , + 15 | ) +6:41:42 PM [vite] Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/main.tsx: Expected corresponding JSX closing tag for . (13:4) + + 11 | + 12 | +> 13 | + | ^ + 14 | , + 15 | ) + 16 | + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/main.tsx:13:4 + 11 | + 12 | + 13 | + | ^ + 14 | , + 15 | ) +6:41:42 PM [vite] Internal server error: /Users/jhag/projects/babymonitor/frontend/src/main.tsx: Expected corresponding JSX closing tag for . (13:4) + + 11 | + 12 | +> 13 | + | ^ + 14 | , + 15 | ) + 16 | + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/main.tsx:13:4 + 11 | + 12 | + 13 | + | ^ + 14 | , + 15 | ) + at constructor (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:365:19) + at TypeScriptParserMixin.raise (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:6599:19) + at TypeScriptParserMixin.jsxParseElementAt (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:4727:16) + at TypeScriptParserMixin.jsxParseElementAt (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:4698:32) + at TypeScriptParserMixin.jsxParseElement (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:4749:17) + at TypeScriptParserMixin.parseExprAtom (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:4759:19) + at TypeScriptParserMixin.parseExprSubscripts (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11081:23) + at TypeScriptParserMixin.parseUpdate (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11066:21) + at TypeScriptParserMixin.parseMaybeUnary (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11046:23) + at TypeScriptParserMixin.parseMaybeUnary (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9837:18) + at TypeScriptParserMixin.parseMaybeUnaryOrPrivate (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10899:61) + at TypeScriptParserMixin.parseExprOps (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10904:23) + at TypeScriptParserMixin.parseMaybeConditional (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10881:23) + at TypeScriptParserMixin.parseMaybeAssign (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10831:21) + at /Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9775:39 + at TypeScriptParserMixin.tryParse (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:6907:20) + at TypeScriptParserMixin.parseMaybeAssign (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9775:18) + at /Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10800:39 + at TypeScriptParserMixin.allowInAnd (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12426:12) + at TypeScriptParserMixin.parseMaybeAssignAllowIn (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10800:17) + at TypeScriptParserMixin.parseMaybeAssignAllowInOrVoidPattern (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12493:17) + at TypeScriptParserMixin.parseExprListItem (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12175:18) + at TypeScriptParserMixin.parseCallExpressionArguments (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11252:22) + at TypeScriptParserMixin.parseCoverCallAndAsyncArrowHead (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11186:29) + at TypeScriptParserMixin.parseSubscript (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11120:19) + at TypeScriptParserMixin.parseSubscript (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9272:18) + at TypeScriptParserMixin.parseSubscripts (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11094:19) + at TypeScriptParserMixin.parseExprSubscripts (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11085:17) + at TypeScriptParserMixin.parseUpdate (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11066:21) + at TypeScriptParserMixin.parseMaybeUnary (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:11046:23) + at TypeScriptParserMixin.parseMaybeUnary (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9837:18) + at TypeScriptParserMixin.parseMaybeUnaryOrPrivate (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10899:61) + at TypeScriptParserMixin.parseExprOps (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10904:23) + at TypeScriptParserMixin.parseMaybeConditional (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10881:23) + at TypeScriptParserMixin.parseMaybeAssign (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10831:21) + at TypeScriptParserMixin.parseMaybeAssign (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9786:20) + at TypeScriptParserMixin.parseExpressionBase (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10784:23) + at /Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10780:39 + at TypeScriptParserMixin.allowInAnd (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12421:16) + at TypeScriptParserMixin.parseExpression (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10780:17) + at TypeScriptParserMixin.parseStatementContent (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12895:23) + at TypeScriptParserMixin.parseStatementContent (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:9508:18) + at TypeScriptParserMixin.parseStatementLike (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12767:17) + at TypeScriptParserMixin.parseModuleItem (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12744:17) + at TypeScriptParserMixin.parseBlockOrModuleBlockBody (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:13316:36) + at TypeScriptParserMixin.parseBlockBody (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:13309:10) + at TypeScriptParserMixin.parseProgram (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12622:10) + at TypeScriptParserMixin.parseTopLevel (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:12612:25) + at TypeScriptParserMixin.parse (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:14488:25) + at TypeScriptParserMixin.parse (/Users/jhag/projects/babymonitor/frontend/node_modules/@babel/parser/lib/index.js:10126:18) +6:41:49 PM [vite] Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/main.tsx: Expected corresponding JSX closing tag for . (13:4) + + 11 | + 12 | +> 13 | + | ^ + 14 | , + 15 | ) + 16 | + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/main.tsx:13:4 + 11 | + 12 | + 13 | + | ^ + 14 | , + 15 | ) +6:42:59 PM [vite] (client) page reload src/main.tsx +6:43:06 PM [vite] (client) Pre-transform error: [postcss] /Users/jhag/projects/babymonitor/frontend/src/index.css:1:1: Unknown word import + Plugin: vite:css + File: /Users/jhag/projects/babymonitor/frontend/src/index.css:1:0 + 1 | import './index.css' + | ^ + 2 | + 3 | @import "tailwindcss"; +6:43:06 PM [vite] (client) Pre-transform error: Failed to resolve import "react-router-dom" from "src/App.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/App.tsx:2:61 + 15 | window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; + 16 | } + 17 | import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom"; + | ^ + 18 | import TrackerPage from "./components/tracker/TrackerPage"; + 19 | import StatsPage from "./components/stats/StatsPage"; +6:43:06 PM [vite] Internal server error: [postcss] /Users/jhag/projects/babymonitor/frontend/src/index.css:1:1: Unknown word import + Plugin: vite:css + File: /Users/jhag/projects/babymonitor/frontend/src/index.css:1:0 + 1 | import './index.css' + | ^ + 2 | + 3 | @import "tailwindcss"; + at Input.error (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/input.js:135:16) + at Parser.unknownWord (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:595:22) + at Parser.other (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:437:12) + at Parser.parse (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:472:16) + at parse (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parse.js:11:12) + at new LazyResult (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/lazy-result.js:165:16) + at Processor.process (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/processor.js:53:14) + at runPostCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43878:52) + at async compilePostCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43848:18) + at async compileCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43703:27) + at async TransformPluginContext.handler (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43019:11) + at async EnvironmentPluginContainer.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42348:18) + at async loadAndTransform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:35740:27) + at async viteTransformMiddleware (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:37264:24) +6:43:06 PM [vite] Internal server error: Failed to resolve import "react-router-dom" from "src/App.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/App.tsx:2:61 + 15 | window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; + 16 | } + 17 | import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom"; + | ^ + 18 | import TrackerPage from "./components/tracker/TrackerPage"; + 19 | import StatsPage from "./components/stats/StatsPage"; + at TransformPluginContext._formatLog (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42553:41) + at TransformPluginContext.error (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42550:16) + at normalizeUrl (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40529:23) + at process.processTicksAndRejections (node:internal/process/task_queues:103:5) + at async file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40648:37 + at async Promise.all (index 3) + at async TransformPluginContext.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40575:7) + at async EnvironmentPluginContainer.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42348:18) + at async loadAndTransform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:35740:27) + at async viteTransformMiddleware (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:37264:24) +6:43:06 PM [vite] (client) Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/components/stats/DailySummary.tsx: Unexpected token, expected "{" (5:28) + + 3 | import { format } from 'date-fns'; + 4 | +> 5 | interface DailySummaryProps = { + | ^ + 6 | from_date: string; + 7 | to_date: string; + 8 | day_start: string; + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/components/stats/DailySummary.tsx:5:28 + 3 | import { format } from 'date-fns'; + 4 | + 5 | interface DailySummaryProps = { + | ^ + 6 | from_date: string; + 7 | to_date: string; +6:43:06 PM [vite] (client) Pre-transform error: Failed to resolve import "../hooks/useSleepSession" from "src/components/tracker/StatusBanner.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/components/tracker/StatusBanner.tsx:2:32 + 16 | } + 17 | var _s = $RefreshSig$(); + 18 | import { useSleepSession } from "../hooks/useSleepSession"; + | ^ + 19 | const StatusBanner = () => { + 20 | _s(); +6:43:06 PM [vite] (client) ✨ new dependencies optimized: react-dom/client, @tanstack/react-query +6:43:06 PM [vite] (client) ✨ optimized dependencies changed. reloading +6:43:14 PM [vite] (client) Pre-transform error: [postcss] /Users/jhag/projects/babymonitor/frontend/src/index.css:1:1: Unknown word import + Plugin: vite:css + File: /Users/jhag/projects/babymonitor/frontend/src/index.css:1:0 + 1 | import './index.css' + | ^ + 2 | + 3 | @import "tailwindcss"; +6:43:14 PM [vite] (client) Pre-transform error: Failed to resolve import "react-router-dom" from "src/App.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/App.tsx:2:61 + 15 | window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; + 16 | } + 17 | import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom"; + | ^ + 18 | import TrackerPage from "./components/tracker/TrackerPage"; + 19 | import StatsPage from "./components/stats/StatsPage"; +6:43:14 PM [vite] (client) Pre-transform error: Failed to resolve import "../hooks/useSleepSession" from "src/components/tracker/StatusBanner.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/components/tracker/StatusBanner.tsx:2:32 + 16 | } + 17 | var _s = $RefreshSig$(); + 18 | import { useSleepSession } from "../hooks/useSleepSession"; + | ^ + 19 | const StatusBanner = () => { + 20 | _s(); +6:43:14 PM [vite] (client) Pre-transform error: /Users/jhag/projects/babymonitor/frontend/src/components/stats/DailySummary.tsx: Unexpected token, expected "{" (5:28) + + 3 | import { format } from 'date-fns'; + 4 | +> 5 | interface DailySummaryProps = { + | ^ + 6 | from_date: string; + 7 | to_date: string; + 8 | day_start: string; + Plugin: vite:react-babel + File: /Users/jhag/projects/babymonitor/frontend/src/components/stats/DailySummary.tsx:5:28 + 3 | import { format } from 'date-fns'; + 4 | + 5 | interface DailySummaryProps = { + | ^ + 6 | from_date: string; + 7 | to_date: string; +6:44:02 PM [vite] Internal server error: [postcss] /Users/jhag/projects/babymonitor/frontend/src/index.css:1:1: Unknown word import + Plugin: vite:css + File: /Users/jhag/projects/babymonitor/frontend/src/index.css:1:0 + 1 | import './index.css' + | ^ + 2 | + 3 | @import "tailwindcss"; + at Input.error (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/input.js:135:16) + at Parser.unknownWord (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:595:22) + at Parser.other (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:437:12) + at Parser.parse (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parser.js:472:16) + at parse (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/parse.js:11:12) + at new LazyResult (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/lazy-result.js:165:16) + at Processor.process (/Users/jhag/projects/babymonitor/frontend/node_modules/postcss/lib/processor.js:53:14) + at runPostCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43878:52) + at async compilePostCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43848:18) + at async compileCSS (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43703:27) + at async TransformPluginContext.handler (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:43019:11) + at async EnvironmentPluginContainer.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42348:18) + at async loadAndTransform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:35740:27) + at async viteTransformMiddleware (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:37264:24) +6:44:02 PM [vite] Internal server error: Failed to resolve import "react-router-dom" from "src/App.tsx". Does the file exist? + Plugin: vite:import-analysis + File: /Users/jhag/projects/babymonitor/frontend/src/App.tsx:2:61 + 15 | window.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform; + 16 | } + 17 | import { BrowserRouter as Router, Routes, Route, Link } from "react-router-dom"; + | ^ + 18 | import TrackerPage from "./components/tracker/TrackerPage"; + 19 | import StatsPage from "./components/stats/StatsPage"; + at TransformPluginContext._formatLog (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42553:41) + at TransformPluginContext.error (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42550:16) + at normalizeUrl (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40529:23) + at process.processTicksAndRejections (node:internal/process/task_queues:103:5) + at async file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40648:37 + at async Promise.all (index 3) + at async TransformPluginContext.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:40575:7) + at async EnvironmentPluginContainer.transform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:42348:18) + at async loadAndTransform (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:35740:27) + at async viteTransformMiddleware (file:///Users/jhag/projects/babymonitor/frontend/node_modules/vite/dist/node/chunks/dep-Dq2t6Dq0.js:37264:24) +6:44:10 PM [vite] (client) page reload tests/e2e/debug.spec.ts +6:46:52 PM [vite] vite.config.ts changed, restarting server... +failed to load config from /Users/jhag/projects/babymonitor/frontend/vite.config.ts +6:46:52 PM [vite] Cannot find package '@tailwindcss/vite' imported from /Users/jhag/projects/babymonitor/frontend/node_modules/.vite-temp/vite.config.ts.timestamp-1776876412327-d253c52b1b806.mjs +6:46:52 PM [vite] server restart failed diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d80c2e6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Baby Sleep Tracker + + +
+ + + \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e37b2cd --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback - serve index.html for all routes that don't match static files + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend container + location /api/ { + rewrite ^/api/(.*)$ /$1 break; + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0e35808 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5277 @@ +{ + "name": "baby-sleep-tracker-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "baby-sleep-tracker-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "lucide-react": "^0.470.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "@vitest/ui": "^2.1.0", + "autoprefixer": "^10.4.20", + "jsdom": "^29.0.2", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + } + }, + "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/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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", + "peer": true, + "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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "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/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.99.2.tgz", + "integrity": "sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.99.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.99.2.tgz", + "integrity": "sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.99.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "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.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "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.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "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.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "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" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "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.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/ui": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-2.1.9.tgz", + "integrity": "sha512-izzd2zmnk8Nl5ECYkW27328RbQ1nKvkm6Bb5DAaz1Gk59EbLkiCMa6OLT0NoaAYTjOFS6N+SMYW1nh4/9ljPiw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "fflate": "^0.8.2", + "flatted": "^3.3.1", + "pathe": "^1.1.2", + "sirv": "^3.0.0", + "tinyglobby": "^0.2.10", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "2.1.9" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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": "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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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/caniuse-lite": { + "version": "1.0.30001790", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001790.tgz", + "integrity": "sha512-bOoxfJPyYo+ds6W0YfptaCWbFnJYjh2Y1Eow5lRv+vI2u8ganPZqNm1JwNh0t2ELQCqIWg4B3dWEusgAmsoyOw==", + "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/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/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/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": "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/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "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/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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" + }, + "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/electron-to-chromium": { + "version": "1.5.343", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.343.tgz", + "integrity": "sha512-YHnQ3MXI08icvL9ZKnEBy05F2EQ8ob01UaMOuMbM8l+4UcAq6MPPbBTJBbsBUg3H8JeZNt+O4fjsoWth3p6IFg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/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/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-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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "dev": true, + "license": "MIT" + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/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/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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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-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/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/jsdom": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.2.tgz", + "integrity": "sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.5", + "@asamuzakjp/dom-selector": "^7.0.6", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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/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/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/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.470.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.470.0.tgz", + "integrity": "sha512-tqYODeoB3qU5gxH33IbL7IcF05EYYzsZQJqdM9HGGHwmoJMPvVLPHO6Plu6HNAfntucZQ41taEw6aUpwOtaoXg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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", + "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/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "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/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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/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/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "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/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "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": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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", + "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/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/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/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "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" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz", + "integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz", + "integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/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-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/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/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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/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/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/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/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "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/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/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": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", + "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.28" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.28", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", + "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "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/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.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "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.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "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.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "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.9", + "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.9", + "@vitest/ui": "2.1.9", + "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.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "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", + "peer": true, + "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": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "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/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/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e89be89 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,39 @@ +{ + "name": "baby-sleep-tracker-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" + }, + "dependencies": { + "@tanstack/react-query": "^5.0.0", + "lucide-react": "^0.470.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.0", + "recharts": "^2.15.0" + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@tailwindcss/vite": "^4.0.0", + "@testing-library/jest-dom": "^6.6.0", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "@vitest/ui": "^2.1.0", + "autoprefixer": "^10.4.20", + "jsdom": "^29.0.2", + "postcss": "^8.4.49", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^2.1.0" + } +} diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..8eb6f89 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,39 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + baseURL: 'http://localhost:5173/', + testDir: './tests', + timeout: 30 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + use: { + baseURL: 'http://localhost:5173/', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: [ + { + command: process.env.CI ? 'npm run preview' : 'npm run dev', + port: 5173, + reuseExistingServer: !process.env.CI, + }, + { + command: 'uv run uvicorn app.main:app --host 0.0.0.0 --port 8000', + port: 8000, + reuseExistingServer: !process.env.CI, + cwd: '../backend', + }, + ], +}); \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..8516480 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import TrackerPage from './components/tracker/TrackerPage'; +import StatsPage from './components/stats/StatsPage'; +import Navbar from './components/Navbar'; + +function App() { + return ( + +
+ +
+ + } /> + } /> + +
+
+
+ ); +} + + +export default App; diff --git a/frontend/src/api/AGENTS.md b/frontend/src/api/AGENTS.md new file mode 100644 index 0000000..9e45eec --- /dev/null +++ b/frontend/src/api/AGENTS.md @@ -0,0 +1,16 @@ +# frontend/src/api + +## OVERVIEW + +API client for backend communication. + +## WHERE TO LOOK + +| File | Purpose | +|------|---------| +| `client.ts` | Fetch client setup | + +## CONVENTIONS + +- TanStack Query hooks for data fetching +- Types from `../types` \ No newline at end of file diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..840d120 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,100 @@ +import type { SleepSessionCreate, SleepSessionUpdate, SleepSessionRead, SessionListResponse } from '@/types'; + +const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; + +export function localToUTC(localValue: string): string { + return localValue; +} + +export async function fetchActiveSession(): Promise { + const response = await fetch(`${BASE_URL}/api/sessions/active`); + if (!response.ok) throw new Error('Failed to fetch active session'); + const data = await response.json(); + return data.session; +} + +export async function fetchSessions(from_date?: string, to_date?: string, limit: number = 200): Promise { + const params = new URLSearchParams(); + if (from_date) params.append('from_date', from_date); + if (to_date) params.append('to_date', to_date); + if (limit) params.append('limit', limit.toString()); + + const response = await fetch(`${BASE_URL}/api/sessions?${params.toString()}`); + if (!response.ok) throw new Error('Failed to fetch sessions'); + return response.json(); +} + +export async function createSession(payload: SleepSessionCreate): Promise { + const utcTime = localToUTC(payload.start_time); + const response = await fetch(`${BASE_URL}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ start_time: utcTime }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to create session'); + } + return response.json(); +} + +export async function endSession(sessionId: number, payload: SleepSessionUpdate): Promise { + const utcTime = payload.end_time ? localToUTC(payload.end_time) : undefined; + const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}/end`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ end_time: utcTime }), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to end session'); + } + return response.json(); +} + +export async function updateSession(sessionId: number, payload: SleepSessionUpdate): Promise { + const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.detail || 'Failed to update session'); + } + return response.json(); +} + +export async function deleteSession(sessionId: number): Promise { + const response = await fetch(`${BASE_URL}/api/sessions/${sessionId}`, { + method: 'DELETE', + }); + if (!response.ok) throw new Error('Failed to delete session'); +} + +export async function fetchDailySummary(from_date: string, to_date: string, day_start: string, day_end: string): Promise { + const fromDT = from_date + 'T00:00:00'; + const toDT = to_date + 'T23:59:59'; + const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end }); + const response = await fetch(`${BASE_URL}/api/stats/daily-summary?${params.toString()}`); + if (!response.ok) throw new Error('Failed to fetch daily summary'); + return response.json(); +} + +export async function fetchTimetable(from_date: string, to_date: string, day_start: string, day_end: string): Promise { + const fromDT = from_date + 'T00:00:00'; + const toDT = to_date + 'T23:59:59'; + const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, day_start, day_end }); + const response = await fetch(`${BASE_URL}/api/stats/timetable?${params.toString()}`); + if (!response.ok) throw new Error('Failed to fetch timetable'); + return response.json(); +} + +export async function fetchWakeSleepTimes(from_date: string, to_date: string, wakeup_ref: string, bedtime_ref: string): Promise { + const fromDT = from_date + 'T00:00:00'; + const toDT = to_date + 'T23:59:59'; + const params = new URLSearchParams({ from_date: fromDT, to_date: toDT, wakeup_ref, bedtime_ref }); + const response = await fetch(`${BASE_URL}/api/stats/wake-sleep-times?${params.toString()}`); + if (!response.ok) throw new Error('Failed to fetch wake/sleep times'); + return response.json(); +} diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..9959818 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,46 @@ +import { Component, ReactNode } from 'react'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error?: Error; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("ErrorBoundary caught an error", error, errorInfo.componentStack); + } + + render() { + if (this.state.hasError) { + return ( + this.props.fallback || ( +
+

Failed to load this component

+ +
+ ) + ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..e028088 --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; + +const Navbar: React.FC = () => { + const location = useLocation(); + + // Helper to check if a path is active + const isActive = (path: string) => location.pathname === path; + + return ( + + ); +}; + +export default Navbar; diff --git a/frontend/src/components/stats/AGENTS.md b/frontend/src/components/stats/AGENTS.md new file mode 100644 index 0000000..2eeec06 --- /dev/null +++ b/frontend/src/components/stats/AGENTS.md @@ -0,0 +1,21 @@ +# frontend/src/components/stats + +## OVERVIEW + +Stats visualization components: SessionList, StatsPage, DailySummary, BedtimePlot, WakeupPlot, SettingsPopover, IntervalPicker, SleepTimetable. + +## WHERE TO LOOK + +| Component | Purpose | +|----------|---------| +| `StatsPage.tsx` | Main stats tab container | +| `DailySummary.tsx` | Day vs. night sleep totals | +| `SleepTimetable.tsx` | Continuous time-slot grid | +| `BedtimePlot.tsx` | Bedtime trend chart | +| `WakeupPlot.tsx` | Wakeup time trend chart | + +## CONVENTIONS + +- Uses Recharts for visualization +- Tailwind v4 for styling (no custom CSS classes) +- Lucide icons for UI elements \ No newline at end of file diff --git a/frontend/src/components/stats/BedtimePlot.tsx b/frontend/src/components/stats/BedtimePlot.tsx new file mode 100644 index 0000000..642b6c7 --- /dev/null +++ b/frontend/src/components/stats/BedtimePlot.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { useStats } from '../../hooks/useStats'; +import { formatMinutesAfter, getToday } from '../../utils/time'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +interface WakeupBedtimeProps { + from_date: string; + to_date: string; + wakeup_ref: string; + bedtime_ref: string; +} + +const BedtimePlot: React.FC = ({ from_date, to_date, bedtime_ref }) => { + const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00'); + const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]); + + useEffect(() => { + if (!wakeupBedtime?.days) { + setChartData([]); + return; + } + + const data = wakeupBedtime.days.map((day) => { + const daysAgo = Math.floor( + (new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24) + ); + return { + date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`, + minutes: day.bedtime_minutes ?? 0, + time: + day.bedtime_minutes !== null + ? formatMinutesAfter(day.bedtime_minutes, bedtime_ref) + : 'N/A', + }; + }).reverse(); + + setChartData(data); + }, [wakeupBedtime, bedtime_ref]); + + if (isLoading) return
Loading plot...
; + if (!chartData.length) { + return
No data available for this period.
; + } + + return ( +
+

Bedtime

+ + + + + formatMinutesAfter(value, bedtime_ref)} + domain={['dataMin - 30', 'dataMax + 30']} + /> + [ + formatMinutesAfter(value, bedtime_ref), + 'Bedtime', + ]} + labelFormatter={(label) => label} + /> + + + +
+ ); +}; + +export default BedtimePlot; \ No newline at end of file diff --git a/frontend/src/components/stats/DailySummary.tsx b/frontend/src/components/stats/DailySummary.tsx new file mode 100644 index 0000000..471ba81 --- /dev/null +++ b/frontend/src/components/stats/DailySummary.tsx @@ -0,0 +1,120 @@ +import { useEffect, useState } from 'react'; +import { useStats } from '../../hooks/useStats'; +import { formatMinutes } from '../../utils/time'; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Cell, +} from 'recharts'; + +interface DailySummaryProps { + from_date: string; + to_date: string; + day_start: string; + day_end: string; +} + +interface ChartDataItem { + date: string; + daySleep: number; + nightSleep: number; + total: number; +} + +const DailySummary: React.FC = ({ from_date, to_date, day_start, day_end }) => { + const { dailySummary, isLoading } = useStats(from_date, to_date, day_start, day_end); + const [chartData, setChartData] = useState([]); + const [avgStats, setAvgStats] = useState({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 }); + + useEffect(() => { + if (!dailySummary?.days) { + setChartData([]); + setAvgStats({ avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 }); + return; + } + + const data = dailySummary.days.map((day) => ({ + date: day.date, + daySleep: day.day_minutes, + nightSleep: day.night_minutes, + total: day.total_minutes, + })); + + setChartData(data.reverse()); + + if (data.length) { + const totals = data.reduce( + (acc, d) => ({ + total: acc.total + d.total, + day: acc.day + d.daySleep, + night: acc.night + d.nightSleep, + }), + { total: 0, day: 0, night: 0 } + ); + const count = data.length; + setAvgStats({ + avgTotal: Math.round(totals.total / count), + avgDay: Math.round(totals.day / count), + avgNight: Math.round(totals.night / count), + count, + }); + } + }, [dailySummary]); + + if (isLoading) return
Loading summary...
; + if (!chartData.length) { + return
No data available for this period.
; + } + + return ( +
+

Daily Summary

+ + + + + + [ + formatMinutes(value), + name === 'daySleep' ? 'Day' : 'Night', + ]} + /> + + + + +
+
+
Avg Total
+
{formatMinutes(avgStats.avgTotal)}
+
+
+
Avg Day
+
{formatMinutes(avgStats.avgDay)}
+
+
+
Avg Night
+
{formatMinutes(avgStats.avgNight)}
+
+
+
+ ); +}; + +export default DailySummary; \ No newline at end of file diff --git a/frontend/src/components/stats/IntervalPicker.tsx b/frontend/src/components/stats/IntervalPicker.tsx new file mode 100644 index 0000000..f13f3a1 --- /dev/null +++ b/frontend/src/components/stats/IntervalPicker.tsx @@ -0,0 +1,28 @@ +import React from 'react'; + +interface IntervalPickerProps { + value: number; + onChange: (days: number) => void; +} + +const INTERVALS = [7, 14, 30, 90]; + +export function IntervalPicker({ value, onChange }: IntervalPickerProps) { + return ( +
+ {INTERVALS.map((days) => ( + + ))} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/stats/SessionList.tsx b/frontend/src/components/stats/SessionList.tsx new file mode 100644 index 0000000..5b6666f --- /dev/null +++ b/frontend/src/components/stats/SessionList.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useSessions } from '../../hooks/useSleepSession'; + +function formatDateTime(isoString: string): string { + const iso = isoString.endsWith('Z') ? isoString : isoString + 'Z'; + const d = new Date(iso); + return d.toLocaleString('sv-SE', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false + }).replace(',', ' '); +} + +export function SessionList() { + const { sessions, isLoading } = useSessions(); + + if (isLoading) return
Loading sessions...
; + if (!sessions.length) return
No sessions recorded.
; + + return ( +
+

Sleep Sessions

+
+ {sessions.map((s) => ( +
+
+ + {formatDateTime(s.start_time)} + + + {s.end_time + ? `${formatDateTime(s.start_time)} → ${formatDateTime(s.end_time)}` + : 'In progress'} + +
+ + {s.end_time && s.start_time + ? `${Math.round((new Date(s.end_time + 'Z').getTime() - new Date(s.start_time + 'Z').getTime()) / 60000)} min` + : '—'} + +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/stats/SettingsPopover.tsx b/frontend/src/components/stats/SettingsPopover.tsx new file mode 100644 index 0000000..6dc32aa --- /dev/null +++ b/frontend/src/components/stats/SettingsPopover.tsx @@ -0,0 +1,118 @@ +import React, { useState, useRef, useEffect } from 'react'; + +interface SettingsPopoverProps { + dayStart: string; + dayEnd: string; + wakeupRef: string; + bedtimeRef: string; + onSave: (settings: { dayStart: string; dayEnd: string; wakeupRef: string; bedtimeRef: string }) => void; +} + +export function SettingsPopover({ dayStart, dayEnd, wakeupRef, bedtimeRef, onSave }: SettingsPopoverProps) { + const [isOpen, setIsOpen] = useState(false); + const [localDayStart, setLocalDayStart] = useState(dayStart); + const [localDayEnd, setLocalDayEnd] = useState(dayEnd); + const [localWakeupRef, setLocalWakeupRef] = useState(wakeupRef); + const [localBedtimeRef, setLocalBedtimeRef] = useState(bedtimeRef); + const popoverRef = useRef(null); + + useEffect(() => { + setLocalDayStart(dayStart); + setLocalDayEnd(dayEnd); + setLocalWakeupRef(wakeupRef); + setLocalBedtimeRef(bedtimeRef); + }, [dayStart, dayEnd, wakeupRef, bedtimeRef]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen]); + + const handleSave = () => { + onSave({ + dayStart: localDayStart, + dayEnd: localDayEnd, + wakeupRef: localWakeupRef, + bedtimeRef: localBedtimeRef, + }); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+

Settings

+ +
+
+ + setLocalDayStart(e.target.value)} + className="w-full p-2 border rounded-md text-sm" +/> +
+
+ + setLocalDayEnd(e.target.value)} + className="w-full p-2 border rounded-md text-sm" +/> +
+
+ + setLocalWakeupRef(e.target.value)} + className="w-full p-2 border rounded-md text-sm" +/> +
+
+ + setLocalBedtimeRef(e.target.value)} + className="w-full p-2 border rounded-md text-sm" +/> +
+
+ + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/stats/SleepTimetable.tsx b/frontend/src/components/stats/SleepTimetable.tsx new file mode 100644 index 0000000..e498f8b --- /dev/null +++ b/frontend/src/components/stats/SleepTimetable.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { useStats } from '../../hooks/useStats'; + +interface TimetableProps { + from_date: string; + to_date: string; + day_start: string; + day_end: string; +}; + +const Timetable: React.FC = ({ from_date, to_date, day_start, day_end }) => { + const { timetable, isLoading } = useStats(from_date, to_date, day_start, day_end); + + if (isLoading) return
Loading timetable...
; + if (!timetable?.slots || timetable.slots.length === 0) { + return
No data available for this period.
; + } + + // Grouping slots by date to make it easier to render rows + const groupedSlots = timetable.slots.reduce((acc: any, slot) => { + if (!acc[slot.date]) acc[slot.date] = []; + acc[slot.date].push(slot); + return acc; + }, {}); + + return ( +
+

Sleep Timetable

+
+ {Object.keys(groupedSlots).map((date) => ( +
+

{date}

+
+ {groupedSlots[date].map((slot: any) => ( +
+ {slot.slot_start} +
+ ))} +
+
+ ))} +
+
+ ); +}; + +export default Timetable; diff --git a/frontend/src/components/stats/StatsPage.tsx b/frontend/src/components/stats/StatsPage.tsx new file mode 100644 index 0000000..0eb72df --- /dev/null +++ b/frontend/src/components/stats/StatsPage.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { IntervalPicker } from './IntervalPicker'; +import { SettingsPopover } from './SettingsPopover'; +import DailySummary from './DailySummary'; +import Timetable from './SleepTimetable'; +import WakeupPlot from './WakeupPlot'; +import BedtimePlot from './BedtimePlot'; +import { SessionList } from './SessionList'; +import { ErrorBoundary } from '../../components/ErrorBoundary'; +import { useSettings } from '../../hooks/useSettings'; +import TestErrorComponent from '../test-error-component'; + +const StatsPage: React.FC = () => { + const { settings, update } = useSettings(); + + const from_date = new Date(new Date().setDate(new Date().getDate() - settings.statsInterval)).toISOString().split('T')[0]; + const to_date = new Date().toISOString().split('T')[0]; + + return ( +
+
+

Sleep Statistics

+ { + update('dayStart', s.dayStart); + update('dayEnd', s.dayEnd); + update('wakeupRef', s.wakeupRef); + update('bedtimeRef', s.bedtimeRef); + }} + /> +
+ + update('statsInterval', days)} + /> + + + + + +
+ + + + + + +
+ + + + + + + + + +
+

Error Boundary Test Area

+ + + +
+ +
+ ); +}; + +export default StatsPage; \ No newline at end of file diff --git a/frontend/src/components/stats/WakeupPlot.tsx b/frontend/src/components/stats/WakeupPlot.tsx new file mode 100644 index 0000000..e1c1f6e --- /dev/null +++ b/frontend/src/components/stats/WakeupPlot.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { useStats } from '../../hooks/useStats'; +import { formatMinutesAfter, getToday } from '../../utils/time'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from 'recharts'; + +interface WakeupBedtimeProps { + from_date: string; + to_date: string; + wakeup_ref: string; + bedtime_ref: string; +} + +const WakeupPlot: React.FC = ({ from_date, to_date, wakeup_ref }) => { + const { wakeupBedtime, isLoading } = useStats(from_date, to_date, '07:00', '20:00'); + const [chartData, setChartData] = useState<{ date: string; minutes: number; time: string }[]>([]); + + useEffect(() => { + if (!wakeupBedtime?.days) { + setChartData([]); + return; + } + + const data = wakeupBedtime.days.map((day) => { + const daysAgo = Math.floor( + (new Date(getToday()).getTime() - new Date(day.date).getTime()) / (1000 * 60 * 60 * 24) + ); + return { + date: daysAgo === 0 ? 'Today' : `${daysAgo}d ago`, + minutes: day.wakeup_minutes ?? 0, + time: + day.wakeup_minutes !== null + ? formatMinutesAfter(day.wakeup_minutes, wakeup_ref) + : 'N/A', + }; + }).reverse(); + + setChartData(data); + }, [wakeupBedtime, wakeup_ref]); + + if (isLoading) return
Loading plot...
; + if (!chartData.length) { + return
No data available for this period.
; + } + + return ( +
+

Wakeup Times

+ + + + + formatMinutesAfter(value, wakeup_ref)} + domain={['dataMin - 30', 'dataMax + 30']} + /> + [ + formatMinutesAfter(value, wakeup_ref), + 'Wakeup', + ]} + labelFormatter={(label) => label} + /> + + + +
+ ); +}; + +export default WakeupPlot; \ No newline at end of file diff --git a/frontend/src/components/test-error-component.tsx b/frontend/src/components/test-error-component.tsx new file mode 100644 index 0000000..238564a --- /dev/null +++ b/frontend/src/components/test-error-component.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const TestErrorComponent = () => { + throw new Error("Test Error"); +}; + +export default TestErrorComponent; \ No newline at end of file diff --git a/frontend/src/components/tracker/SleepButton.tsx b/frontend/src/components/tracker/SleepButton.tsx new file mode 100644 index 0000000..14439bd --- /dev/null +++ b/frontend/src/components/tracker/SleepButton.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useSleepSession } from '../../hooks/useSleepSession'; + +interface SleepButtonProps { + time: string; +} + +const SleepButton: React.FC = ({ time }) => { + const { activeSession, createSession, endSession, isCreating, isEnding } = useSleepSession(); + const isSleeping = !!activeSession; + + console.log('DEBUG: SleepButton State:', { + activeSession, + isSleeping, + isCreating, + isEnding + }); + + const handleClick = async () => { + try { + if (isSleeping && activeSession?.id) { + await endSession({ sessionId: activeSession.id, endTime: time }); + } else { + await createSession({ start_time: time }); + } + } catch (error: any) { + console.error('Failed to update sleep session:', error?.message || error); + } + }; + + return ( + + ); +}; + +export default SleepButton; diff --git a/frontend/src/components/tracker/StatusBanner.tsx b/frontend/src/components/tracker/StatusBanner.tsx new file mode 100644 index 0000000..6c9108a --- /dev/null +++ b/frontend/src/components/tracker/StatusBanner.tsx @@ -0,0 +1,42 @@ +import { useState, useEffect } from 'react'; +import { useSleepSession } from '../../hooks/useSleepSession'; +import { formatElapsedTime } from '../../utils/time'; + +const StatusBanner = () => { + const { activeSession, isLoading } = useSleepSession(); + const [elapsed, setElapsed] = useState(''); + + useEffect(() => { + if (!activeSession) { + setElapsed(''); + return; + } + + const updateElapsed = () => { + setElapsed(formatElapsedTime(activeSession.start_time)); + }; + + updateElapsed(); + const interval = setInterval(updateElapsed, 1000); + return () => clearInterval(interval); + }, [activeSession?.start_time]); + + if (isLoading) return
Loading...
; + + if (activeSession) { + return ( +
+ Baby is sleeping 😴 + {elapsed && ({elapsed})} +
+ ); + } + + return ( +
+ Baby is awake 👶 +
+ ); +}; + +export default StatusBanner; \ No newline at end of file diff --git a/frontend/src/components/tracker/TimeAdjuster.tsx b/frontend/src/components/tracker/TimeAdjuster.tsx new file mode 100644 index 0000000..986e534 --- /dev/null +++ b/frontend/src/components/tracker/TimeAdjuster.tsx @@ -0,0 +1,58 @@ +import React, { useState, useEffect } from 'react'; + +interface TimeAdjusterProps { + onTimeChange: (time: string) => void; + defaultValue?: string; +} + +function toLocalDatetimeInputValue(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function toUTCFromLocalInput(inputValue: string): string { + const [datePart, timePart] = inputValue.split('T'); + const [year, month, day] = datePart.split('-').map(Number); + const [hours, minutes] = timePart.split(':').map(Number); + const localDate = new Date(year, month - 1, day, hours, minutes); + return localDate.toISOString(); +} + +const TimeAdjuster: React.FC = ({ onTimeChange, defaultValue }) => { + const [value, setValue] = useState(() => { + if (defaultValue) { + const d = new Date(defaultValue); + return toLocalDatetimeInputValue(d); + } + return toLocalDatetimeInputValue(new Date()); + }); + + useEffect(() => { + onTimeChange(toUTCFromLocalInput(value)); + }, [value, onTimeChange]); + + const resetToNow = () => { + const now = toLocalDatetimeInputValue(new Date()); + setValue(now); + onTimeChange(toUTCFromLocalInput(now)); + }; + + return ( +
+ setValue(e.target.value)} + className="p-3 border rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 text-lg" + /> + +
+ ); +}; + +export default TimeAdjuster; diff --git a/frontend/src/components/tracker/TrackerPage.tsx b/frontend/src/components/tracker/TrackerPage.tsx new file mode 100644 index 0000000..9f26f5e --- /dev/null +++ b/frontend/src/components/tracker/TrackerPage.tsx @@ -0,0 +1,22 @@ +import React, { useState } from 'react'; +import StatusBanner from './StatusBanner'; +import SleepButton from './SleepButton'; +import TimeAdjuster from './TimeAdjuster'; + +const TrackerPage: React.FC = () => { + const [time, setTime] = useState(new Date().toISOString().slice(0, 16)); + + return ( +
+ +
+ +
+ +
+
+
+ ); +}; + +export default TrackerPage; diff --git a/frontend/src/hooks/AGENTS.md b/frontend/src/hooks/AGENTS.md new file mode 100644 index 0000000..c8fccbe --- /dev/null +++ b/frontend/src/hooks/AGENTS.md @@ -0,0 +1,19 @@ +# frontend/src/hooks + +## OVERVIEW + +Custom React hooks: useStats, useSettings, useSleepSession. + +## WHERE TO LOOK + +| Hook | Purpose | +|------|---------| +| `useSleepSession.ts` | Session start/stop/CRUD | +| `useStats.ts` | Stats query hooks | +| `useSettings.ts` | App settings state | + +## CONVENTIONS + +- TanStack Query v5 for caching +- React 19 hooks +- Export typed interfaces \ No newline at end of file diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts new file mode 100644 index 0000000..c587fb3 --- /dev/null +++ b/frontend/src/hooks/useSettings.ts @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; + +const STORAGE_KEY = 'baby-sleep-settings'; + +interface Settings { + dayStart: string; + dayEnd: string; + wakeupRef: string; + bedtimeRef: string; + statsInterval: number; +} + +const DEFAULT_SETTINGS: Settings = { + dayStart: '07:00', + dayEnd: '20:00', + wakeupRef: '04:00', + bedtimeRef: '19:00', + statsInterval: 7, +}; + +export function useSettings() { + const [settings, setSettings] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_SETTINGS; + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + try { + return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) }; + } catch { + return DEFAULT_SETTINGS; + } + } + return DEFAULT_SETTINGS; + }); + + useEffect(() => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + }, [settings]); + + const update = (key: keyof Settings, value: string | number) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }; + + return { settings, update }; +} \ No newline at end of file diff --git a/frontend/src/hooks/useSleepSession.ts b/frontend/src/hooks/useSleepSession.ts new file mode 100644 index 0000000..a616bd9 --- /dev/null +++ b/frontend/src/hooks/useSleepSession.ts @@ -0,0 +1,73 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { fetchActiveSession, createSession, endSession, fetchSessions, deleteSession, updateSession } from '../api/client'; +import type { SleepSessionRead } from '../types'; + +export function useSleepSession() { + const queryClient = useQueryClient(); + + const activeSessionQuery = useQuery({ + queryKey: ['activeSession'], + queryFn: fetchActiveSession, + refetchInterval: 30000, // Poll every 30 seconds + }); + + const createMutation = useMutation({ + mutationFn: createSession, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['activeSession'] }); + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); + + const endMutation = useMutation({ + mutationFn: ({ sessionId, endTime }: { sessionId: number; endTime: string }) => + endSession(sessionId, { end_time: endTime }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['activeSession'] }); + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); + + return { + activeSession: activeSessionQuery.data, + isLoading: activeSessionQuery.isLoading, + isFetching: activeSessionQuery.isFetching, + createSession: createMutation.mutateAsync, + endSession: endMutation.mutateAsync, + isCreating: createMutation.isPending, + isEnding: endMutation.isPending, + }; +} + +export function useSessions() { + const queryClient = useQueryClient(); + + const sessionsQuery = useQuery({ + queryKey: ['sessions'], + queryFn: () => fetchSessions(), + }); + + const deleteMutation = useMutation({ + mutationFn: deleteSession, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); + + const updateMutation = useMutation({ + mutationFn: ({ sessionId, payload }: { sessionId: number; payload: any }) => + updateSession(sessionId, payload), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['sessions'] }); + }, + }); + + return { + sessions: sessionsQuery.data?.sessions || [], + total: sessionsQuery.data?.total || 0, + isLoading: sessionsQuery.isLoading, + isDeleting: deleteMutation.isPending, + deleteSession: deleteMutation.mutateAsync, + updateSession: updateMutation.mutateAsync, + }; +} diff --git a/frontend/src/hooks/useStats.ts b/frontend/src/hooks/useStats.ts new file mode 100644 index 0000000..93871d6 --- /dev/null +++ b/frontend/src/hooks/useStats.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchDailySummary, fetchTimetable, fetchWakeSleepTimes } from '../api/client'; +import type { DailySummaryResponse, TimetableResponse, WakeupBedtimeResponse } from '../types'; + +export function useStats( + from_date: string, + to_date: string, + day_start = '07:00', + day_end = '20:00', + wakeup_ref = '04:00', + bedtime_ref = '19:00' +) { + const dailySummaryQuery = useQuery({ + queryKey: ['dailySummary', from_date, to_date, day_start, day_end], + queryFn: () => fetchDailySummary(from_date, to_date, day_start, day_end), + }); + + const timetableQuery = useQuery({ + queryKey: ['timetable', from_date, to_date, day_start, day_end], + queryFn: () => fetchTimetable(from_date, to_date, day_start, day_end), + }); + + const wakeupBedtimeQuery = useQuery({ + queryKey: ['wakeSleepTimes', from_date, to_date, wakeup_ref, bedtime_ref], + queryFn: () => fetchWakeSleepTimes(from_date, to_date, wakeup_ref, bedtime_ref), + }); + + return { + dailySummary: dailySummaryQuery.data, + timetable: timetableQuery.data, + wakeupBedtime: wakeupBedtimeQuery.data, + isLoading: dailySummaryQuery.isLoading || timetableQuery.isLoading || wakeupBedtimeQuery.isLoading, + }; +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..876304e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import './index.css' +import App from './App' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +const queryClient = new QueryClient() + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..d9474bb --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,71 @@ +export interface SleepSession { + id: number; + start_time: string; + end_time: string | null; + notes: string | null; + created_at: string; + updated_at: string; +} + +export interface SleepSessionCreate { + start_time: string; +} + +export interface SleepSessionUpdate { + end_time?: string; +} + +export type SleepSessionRead = SleepSession; + +export interface SessionListResponse { + sessions: SleepSessionRead[]; + total: number; +} + +export interface SessionListResponseData { + sessions: SleepSessionRead[]; + total: number; +} + +export interface SleepSessionCreatePayload { + start_time: string; +} + +export interface SleepSessionUpdatePayload { + start_time?: string; + end_time?: string; + notes?: string; +} + +// Add other types as needed for stats +export interface DailySummaryDay { + date: string; + total_minutes: number; + day_minutes: number; + night_minutes: number; +} + +export interface DailySummaryResponse { + days: DailySummaryDay[]; +} + +export interface TimetableSlot { + date: string; + slot_start: string; + slot_end: string; + sleeping: boolean; +} + +export interface TimetableResponse { + slots: TimetableSlot[]; +} + +export interface WakeupBedtimeDay { + date: string; + wakeup_minutes: number | null; + bedtime_minutes: number | null; +} + +export interface WakeupBedtimeResponse { + days: WakeupBedtimeDay[]; +} diff --git a/frontend/src/utils/chartHelpers.ts b/frontend/src/utils/chartHelpers.ts new file mode 100644 index 0000000..d109db0 --- /dev/null +++ b/frontend/src/utils/chartHelpers.ts @@ -0,0 +1,146 @@ +/** + * Chart data transformation utilities for baby sleep tracker + */ + +import type { + DailySummaryDay, + TimetableSlot, + WakeupBedtimeDay, +} from "../types"; + +/** + * Build timetable slots for visualization + * @param slots - raw timetable slots from API + * @returns grouped slots by date + */ +export function buildTimetableSlots(slots: TimetableSlot[]): Record { + return slots.reduce( + (acc, slot) => { + if (!acc[slot.date]) { + acc[slot.date] = []; + } + acc[slot.date].push(slot); + return acc; + }, + {} as Record + ); +} + +/** + * Transform wakeup minutes for Recharts LineChart + * @param days - wakeup/bedtime data from API + * @returns array formatted for LineChart + */ +export function computeWakeupMinutes( + days: WakeupBedtimeDay[], + wakeupRef: string = "04:00" +): { date: string; minutes: number; time: string }[] { + return days.map((day) => ({ + date: day.date, + minutes: day.wakeup_minutes ?? 0, + time: + day.wakeup_minutes !== null + ? formatMinutesAfter(day.wakeup_minutes, wakeupRef) + : "N/A", + })); +} + +/** + * Transform bedtime minutes for Recharts LineChart + * @param days - wakeup/bedtime data from API + * @returns array formatted for LineChart + */ +export function computeBedtimeMinutes( + days: WakeupBedtimeDay[], + bedtimeRef: string = "19:00" +): { date: string; minutes: number; time: string }[] { + return days.map((day) => ({ + date: day.date, + minutes: day.bedtime_minutes ?? 0, + time: + day.bedtime_minutes !== null + ? formatMinutesAfter(day.bedtime_minutes, bedtimeRef) + : "N/A", + })); +} + +/** + * Transform daily summary for Recharts BarChart (vertical layout) + * @param days - daily summary data from API + * @returns array formatted for BarChart + */ +export function computeDailySummaryData( + days: DailySummaryDay[] +): { date: string; daySleep: number; nightSleep: number; total: number }[] { + return days.map((day) => ({ + date: day.date, + daySleep: day.day_minutes, + nightSleep: day.night_minutes, + total: day.total_minutes, + })); +} + +/** + * Calculate average summary stats from daily data + * @param days - daily summary data from API + * @returns averages for total, day, and night sleep + */ +export function computeAverageStats(days: DailySummaryDay[]): { + avgTotal: number; + avgDay: number; + avgNight: number; + count: number; +} { + if (!days.length) { + return { avgTotal: 0, avgDay: 0, avgNight: 0, count: 0 }; + } + + const totals = days.reduce( + (acc, day) => ({ + total: acc.total + day.total_minutes, + day: acc.day + day.day_minutes, + night: acc.night + day.night_minutes, + }), + { total: 0, day: 0, night: 0 } + ); + + const count = days.length; + return { + avgTotal: Math.round(totals.total / count), + avgDay: Math.round(totals.day / count), + avgNight: Math.round(totals.night / count), + count, + }; +} + +/** + * Convert minutes after reference to clock time string + * Internal helper - kept for chartHelpers module + */ +function formatMinutesAfter(minutes: number, reference: string): string { + const [refHours, refMinutes] = reference.split(":").map(Number); + const totalRefMinutes = refHours * 60 + refMinutes; + const targetMinutes = totalRefMinutes + minutes; + const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440; + const hours = Math.floor(normalizedMinutes / 60); + const mins = normalizedMinutes % 60; + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; +} + +/** + * Reverse date order (newest first for charts) + * @param days - array of day data + * @returns reversed array + */ +export function reverseDateOrder(days: T[]): T[] { + return [...days].reverse(); +} + +/** + * Filter days to only include those with data + * @param days - array of day data + * @returns filtered array + */ +export function filterDaysWithData(days: T[]): T[] { + return days.filter((day) => (day.total_minutes ?? 0) > 0); +} \ No newline at end of file diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts new file mode 100644 index 0000000..f67d351 --- /dev/null +++ b/frontend/src/utils/time.ts @@ -0,0 +1,118 @@ +/** + * Time formatting utilities for baby sleep tracker + */ + +/** + * Convert minutes after a reference time to clock time string + * @param minutes - minutes after reference time (can be negative) + * @param reference - reference time as HH:MM string + * @returns formatted time string like "07:15" + */ +export function formatMinutesAfter(minutes: number | null, reference: string = "04:00"): string { + if (minutes === null) return "N/A"; + + const [refHours, refMinutes] = reference.split(":").map(Number); + const totalRefMinutes = refHours * 60 + refMinutes; + + const targetMinutes = totalRefMinutes + minutes; + // Handle wrap around midnight + const normalizedMinutes = ((targetMinutes % 1440) + 1440) % 1440; + const hours = Math.floor(normalizedMinutes / 60); + const mins = normalizedMinutes % 60; + + return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`; +} + +/** + * Convert a Date to datetime-local input value format (local time) + * @param date - Date object + * @returns string in "YYYY-MM-DDTHH:MM" format + */ +export function toLocalDatetimeInputValue(date: Date): string { + const offset = date.getTimezoneOffset() * 60 * 1000; + const localDate = new Date(date.getTime() - offset); + return localDate.toISOString().slice(0, 16); +} + +/** + * Format elapsed time in human-readable format + * @param startTime - start time as ISO string or Date + * @returns formatted string like "2h 35m" or "just now" + */ +export function formatElapsedTime(startTime: string | Date): string { + const start = typeof startTime === "string" ? new Date(startTime) : startTime; + const now = new Date(); + const diffMs = now.getTime() - start.getTime(); + + if (diffMs < 0) return "just now"; + + const totalMinutes = Math.floor(diffMs / 60000); + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + if (hours === 0 && minutes === 0) return "just now"; + if (hours === 0) return `${minutes}m`; + if (minutes === 0) return `${hours}h`; + return `${hours}h ${minutes}m`; +} + +/** + * Clamp a date to day boundaries (start or end of day) + * @param date - Date to clamp + * @param bound - 'start' for 00:00, 'end' for 23:59:59.999 + * @returns new Date clamped to day boundary + */ +export function clampToDay(date: Date, bound: "start" | "end"): Date { + const result = new Date(date); + if (bound === "start") { + result.setHours(0, 0, 0, 0); + } else { + result.setHours(23, 59, 59, 999); + } + return result; +} + +/** + * Parse HH:MM time string to Date on a given day + * @param timeStr - HH:MM format + * @param baseDate - base date (defaults to today) + * @returns Date object + */ +export function parseTime(timeStr: string, baseDate: Date = new Date()): Date { + const [hours, minutes] = timeStr.split(":").map(Number); + const result = new Date(baseDate); + result.setHours(hours, minutes, 0, 0); + return result; +} + +/** + * Format minutes as hours and minutes string + * @param minutes - total minutes + * @returns formatted string like "12h 30m" + */ +export function formatMinutes(minutes: number): string { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + if (hours === 0) return `${mins}m`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} + +/** + * Get date string for N days ago + * @param days - number of days ago + * @returns ISO date string (YYYY-MM-DD) + */ +export function getDaysAgo(days: number): string { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString().split("T")[0]; +} + +/** + * Get date string for today + * @returns ISO date string (YYYY-MM-DD) + */ +export function getToday(): string { + return new Date().toISOString().split("T")[0]; +} \ No newline at end of file diff --git a/frontend/stats-tab-test.png b/frontend/stats-tab-test.png new file mode 100644 index 0000000000000000000000000000000000000000..3ddab50b98da0d74381bd9f7089dc0c99ac14baf GIT binary patch literal 4254 zcmeAS@N?(olHy`uVBq!ia0y~yU#KiDjWttl9P!CsJDrMnSo)#sPJf*j3$WD+%Q@c zj24fhb;D@IINB;0Z4!+(6S23Efi38O(f0CadwI0IJlb9!bnWG?%4I8oO;~r(SOCQZ p_y?d#|NkF7qH+kxU;`P+%<#gUW5K!N4KhFx22WQ%mvv4FO#o+H0uulL literal 0 HcmV?d00001 diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..4713d40 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,13 @@ +import type { Config } from 'tailwindcss'; +import tailwindcss from '@tailwindcss/vite'; + +export default { + content: [ + './index.html', + './src/**/*.{js,ts,jsx,tsx}', + ], + theme: { + extend: {}, + }, + plugins: [], +} satisfies Config; diff --git a/frontend/tests/e2e/connectivity.spec.ts b/frontend/tests/e2e/connectivity.spec.ts new file mode 100644 index 0000000..c49f4f4 --- /dev/null +++ b/frontend/tests/e2e/connectivity.spec.ts @@ -0,0 +1,7 @@ +import { test, expect } from '@playwright/test'; + +test('simple connectivity test', async ({ page }) => { + await page.goto('http://localhost:5173/'); + console.log('Page title:', await page.title()); + expect(true).toBe(true); +}); diff --git a/frontend/tests/e2e/smoke.spec.ts b/frontend/tests/e2e/smoke.spec.ts new file mode 100644 index 0000000..fba8e41 --- /dev/null +++ b/frontend/tests/e2e/smoke.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test'; + +test('page loads without critical errors', async ({ page }) => { + const errors: string[] = []; + page.on('console', msg => { + if (msg.type() === 'error') { + errors.push(msg.text()); + } + }); + + await page.goto('http://localhost:5173/'); + await expect(page).toHaveTitle('Baby Sleep Tracker'); + + const awakeBanner = page.locator('text=Baby is awake'); + const sleepingBanner = page.locator('text=Baby is sleeping'); + const bannerVisible = await awakeBanner.or(sleepingBanner).isVisible(); + expect(bannerVisible).toBe(true); + + if (errors.length > 0) { + console.log('Console errors:', errors); + } + expect(errors.length).toBe(0); +}); \ No newline at end of file diff --git a/frontend/tests/e2e/stats.spec.ts b/frontend/tests/e2e/stats.spec.ts new file mode 100644 index 0000000..be27a1a --- /dev/null +++ b/frontend/tests/e2e/stats.spec.ts @@ -0,0 +1,71 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Stats Tab', () => { + test.beforeEach(async ({ page }) => { + await page.goto('http://localhost:5173/stats'); + await page.waitForTimeout(1000); + }); + + test('1. Stats tab is reachable via tab navigation', async ({ page }) => { + await page.goto('http://localhost:5173/'); + await page.getByRole('link', { name: /Stats/i }).click(); + await page.waitForTimeout(500); + await expect(page.getByRole('heading', { name: /Sleep Statistics/i })).toBeVisible(); + }); + + test('2. Interval picker changes interval', async ({ page }) => { + await expect(page.getByRole('button', { name: /7d/i })).toBeVisible(); + + await page.getByRole('button', { name: /14d/i }).click(); + await page.waitForTimeout(500); + + await expect(page.getByRole('button', { name: /14d/i })).toBeVisible(); + }); + + test('3. Timetable renders rows for selected interval', async ({ page }) => { + const timetable = page.locator('text=Sleep Timetable'); + if (await timetable.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(page.locator('text=00:00').first()).toBeVisible(); + } + }); + + test('4. Wakeup and bedtime plots render SVG lines', async ({ page }) => { + await page.getByRole('heading', { name: /Wakeup Times/i }).waitFor({ timeout: 2000 }); + await expect(page.locator('h3:has-text("Wakeup Times")')).toBeVisible(); + + await page.locator('h3:has-text("Bedtime")').first().waitFor({ timeout: 2000 }); + await expect(page.locator('h3:has-text("Bedtime")').first()).toBeVisible(); + }); + + test('5. Settings popover for day/night boundary saves to localStorage and re-fetches', async ({ page }) => { + await page.getByRole('button', { name: /Settings/i }).click(); + await page.waitForTimeout(300); + + await expect(page.locator('text=Day starts')).toBeVisible(); + + await page.getByLabel('Day starts (HH:MM)').fill('08:00'); + await page.getByRole('button', { name: /Save/i }).click(); + await page.waitForTimeout(500); + + const localStorage = await page.evaluate(() => { + return window.localStorage.getItem('baby-sleep-settings'); + }); + expect(localStorage).toContain('08:00'); + }); + + test('6. Daily Summary shows chart with bars', async ({ page }) => { + await expect(page.locator('text=Daily Summary')).toBeVisible(); + + const chartBars = page.locator('.recharts-bar-rectangle'); + if (await chartBars.first().isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(chartBars.first()).toBeVisible(); + } + }); + + test('7. Average summaries display below chart', async ({ page }) => { + const avgTotal = page.locator('text=Avg Total'); + if (await avgTotal.isVisible({ timeout: 1000 }).catch(() => false)) { + await expect(avgTotal).toBeVisible(); + } + }); +}); \ No newline at end of file diff --git a/frontend/tests/e2e/stop-sleep.spec.ts b/frontend/tests/e2e/stop-sleep.spec.ts new file mode 100644 index 0000000..758ca96 --- /dev/null +++ b/frontend/tests/e2e/stop-sleep.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@playwright/test'; + +test('stop sleep gives 422', async ({ page }) => { + const errors: string[] = []; + page.on('response', res => { + if (res.url().includes('/api/sessions/') && res.status() >= 400) { + errors.push(`${res.status()} at ${res.url()}`); + } + }); + + await page.goto('http://localhost:5173/'); + + await page.getByRole('button', { name: /Start Sleep/i }).click(); + await page.waitForTimeout(1000); + + await page.getByRole('button', { name: /Stop Sleep/i }).click(); + await page.waitForTimeout(1000); + + console.log('Errors:', errors); + expect(errors.length).toBe(0); +}); \ No newline at end of file diff --git a/frontend/tests/e2e/tracker.spec.ts b/frontend/tests/e2e/tracker.spec.ts new file mode 100644 index 0000000..bbc96dd --- /dev/null +++ b/frontend/tests/e2e/tracker.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from '@playwright/test'; + +test.describe('Tracker Flow', () => { + test.beforeEach(async ({ page }) => { + // 1. Navigate to the app + await page.goto('http://localhost:5173/'); + + // 2. Cleanup: If there is an active session, end it. + const stopBtn = page.getByRole('button', { name: /Stop Sleep/i }); + if (await stopBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await stopBtn.click(); + // Wait for the session to end and button to return to "Start Sleep" or be visible again. + await expect(page.getByRole('button', { name: /Start Sleep/i })).toBeVisible({ timeout: 5000 }); + } + }); + + test('1. page loads — Start Sleep button is visible', async ({ page }) => { + // By default, there should be no active session (after cleanup), so "Baby is awake" should be visible. + await expect(page.getByRole('button', { name: /Start Sleep/i })).toBeVisible(); + await expect(page.getByText(/Baby is awake/i)).toBeVisible(); + }); + + test('2. click Start Sleep — button changes to Stop Sleep, status banner updates', async ({ page }) => { + const startBtn = page.getByRole('button', { name: /Start Sleep/i }); + await startBtn.click(); + + // The button will temporarily show "Processing..." (disabled). + // We wait for it to transition to the active session state: "Stop Sleep". + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + await expect(page.getByText(/Baby is sleeping/i)).toBeVisible(); + }); + + test('3. adjust start time to 5 minutes ago — verify API receives adjusted time', async ({ page }) => { + const datetimeInput = page.locator('input[type="datetime-local"]'); + + const now = new Date(); + const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000); + const adjustedTime = fiveMinAgo.toISOString().slice(0, 16); + await datetimeInput.fill(adjustedTime); + + await page.getByRole('button', { name: /Start Sleep/i }).click(); + + // Wait for transition to "Stop Sleep" + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + await expect(page.getByText(/Baby is sleeping/i)).toBeVisible(); + }); + + test('4. click Stop Sleep — button returns to Start Sleep', async ({ page }) => { + // 1. Start a session + await page.getByRole('button', { name: /Start Sleep/i }).click(); + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + + // 2. Stop the session + await page.getByRole('button', { name: /Stop Sleep/i }).click(); + // After clicking, it should return to "Start Sleep" + await expect(page.getByRole('button', { name: /Start Sleep/i })).toBeVisible(); + }); + + test('5. reload page — UI correctly reflects state from API', async ({ page }) => { + // 1. Start a session + await page.getByRole('button', { name: /Start Sleep/i }).click(); + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + + // 2. Reload the page + await page.reload(); + + // 3. Check if state is persistent from API + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + await expect(page.getByText(/Baby is sleeping/i)).toBeVisible(); + }); + + test('6. Reset to now link resets the time field', async ({ page }) => { + const datetimeInput = page.locator('input[type="datetime-local"]'); + + // Fill with a dummy value to see if it resets + const futureTime = new Date(Date.now() + 10 * 60 * 1000).toISOString().slice(0, 16); + await datetimeInput.fill(futureTime); + + // Click Reset + await page.getByRole('button', { name: /Reset to now/i }).click(); + + // Value should be updated (might slightly differ due to seconds but we check visibility/existence) + const newValue = await datetimeInput.inputValue(); + expect(newValue).not.toBe(futureTime); + }); + + test('7. elapsed time shows for active session', async ({ page }) => { + await page.getByRole('button', { name: /Start Sleep/i }).click(); + await expect(page.getByRole('button', { name: /Stop Sleep/i })).toBeVisible(); + + // Verify that elapsed time (e.g., "1m" or "just now") is visible + await expect(page.getByText(/(\d+h|\d+m|just\s*now)/i)).toBeVisible(); + }); +}); diff --git a/frontend/tests/simple.test.ts b/frontend/tests/simple.test.ts new file mode 100644 index 0000000..eeb5ba2 --- /dev/null +++ b/frontend/tests/simple.test.ts @@ -0,0 +1,5 @@ +import { test, expect } from 'vitest'; + +test('simple test', () => { + expect(1 + 1).toBe(2); +}); diff --git a/frontend/tests/unit/SleepButton.test.tsx b/frontend/tests/unit/SleepButton.test.tsx new file mode 100644 index 0000000..64fcc49 --- /dev/null +++ b/frontend/tests/unit/SleepButton.test.tsx @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import SleepButton from '../../src/components/tracker/SleepButton'; +import { useSleepSession } from '../../src/hooks/useSleepSession'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('../../src/hooks/useSleepSession', () => ({ + useSleepSession: vi.fn(), +})); + +const createTestQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}); + +describe('SleepButton', () => { + let queryClient: any; + + beforeEach(() => { + queryClient = createTestQueryClient(); + vi.clearAllMocks(); + }); + + const renderWithProviders = (ui: React.ReactElement) => { + return render( + + {ui} + + ); + }; + + it('renders "Start Sleep" when no active session', async () => { + (useSleepSession as any).mockReturnValue({ + activeSession: null, + isCreating: false, + isEnding: false, + }); + + renderWithProviders(); + expect(screen.getByRole('button', { name: /Start Sleep/i })).toBeInTheDocument(); + }); + + it('renders "Stop Sleep" when session is active', async () => { + (useSleepSession as any).mockReturnValue({ + activeSession: { id: 1, start_time: '2024-01-01T12:00' }, + isCreating: false, + isEnding: false, + }); + + renderWithProviders(); + expect(screen.getByRole('button', { name: /Stop Sleep/i })).toBeInTheDocument(); + }); + + it('calls endSession when session is active and button is clicked', async () => { + const endSessionMock = vi.fn(); + (useSleepSession as any).mockReturnValue({ + activeSession: { id: 1, start_time: '2024-01-01T12:00' }, + isCreating: false, + isEnding: false, + endSession: endSessionMock, + }); + + renderWithProviders(); + const button = screen.getByRole('button', { name: /Stop Sleep/i }); + fireEvent.click(button); + + await waitFor(() => expect(endSessionMock).toHaveBeenCalled()); + }); +}); diff --git a/frontend/tests/unit/TimeAdjuster.test.tsx b/frontend/tests/unit/TimeAdjuster.test.tsx new file mode 100644 index 0000000..f0fae4b --- /dev/null +++ b/frontend/tests/unit/TimeAdjuster.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TimeAdjuster from '../../src/components/tracker/TimeAdjuster'; + +describe('TimeAdjuster', () => { + it('renders with default value', () => { + const { container } = render( {}} />); + const input = container.querySelector('input'); + expect(input).toBeInTheDocument(); + }); + + it('calls onTimeChange when value changes', () => { + const handleChange = vi.fn(); + const { container } = render(); + const input = container.querySelector('input'); + + fireEvent.change(input!, { target: { value: '2024-01-01T12:00' } }); + expect(handleChange).toHaveBeenCalledWith('2024-01-01T12:00'); + }); + + it('resets to now when reset button is clicked', async () => { + const handleChange = vi.fn(); + render(); + const resetButton = screen.getByText(/Reset to now/i); + + fireEvent.click(resetButton); + expect(handleChange).toHaveBeenCalled(); + }); +}); diff --git a/frontend/tests/unit/chartHelpers.test.ts b/frontend/tests/unit/chartHelpers.test.ts new file mode 100644 index 0000000..b447cff --- /dev/null +++ b/frontend/tests/unit/chartHelpers.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { + buildTimetableSlots, + computeWakeupMinutes, + computeBedtimeMinutes, + computeDailySummaryData, + computeAverageStats, + reverseDateOrder, + filterDaysWithData, +} from '../../src/utils/chartHelpers'; +import type { TimetableSlot, WakeupBedtimeDay, DailySummaryDay } from '../../src/types'; + +describe('buildTimetableSlots', () => { + it('groups slots by date', () => { + const slots: TimetableSlot[] = [ + { date: '2024-01-15', slot_start: '00:00', slot_end: '00:15', sleeping: true }, + { date: '2024-01-15', slot_start: '00:15', slot_end: '00:30', sleeping: false }, + { date: '2024-01-16', slot_start: '00:00', slot_end: '00:15', sleeping: true }, + ]; + const result = buildTimetableSlots(slots); + expect(Object.keys(result)).toHaveLength(2); + expect(result['2024-01-15']).toHaveLength(2); + expect(result['2024-01-16']).toHaveLength(1); + }); +}); + +describe('computeWakeupMinutes', () => { + it('transforms wakeup data for chart', () => { + const days: WakeupBedtimeDay[] = [ + { date: '2024-01-15', wakeup_minutes: 195, bedtime_minutes: 75 }, + ]; + const result = computeWakeupMinutes(days, '04:00'); + expect(result[0].time).toBe('07:15'); + expect(result[0].minutes).toBe(195); + }); + + it('handles null values', () => { + const days: WakeupBedtimeDay[] = [ + { date: '2024-01-15', wakeup_minutes: null, bedtime_minutes: null }, + ]; + const result = computeWakeupMinutes(days, '04:00'); + expect(result[0].time).toBe('N/A'); + }); +}); + +describe('computeBedtimeMinutes', () => { + it('transforms bedtime data for chart', () => { + const days: WakeupBedtimeDay[] = [ + { date: '2024-01-15', wakeup_minutes: 195, bedtime_minutes: 75 }, + ]; + const result = computeBedtimeMinutes(days, '19:00'); + expect(result[0].time).toBe('20:15'); + }); +}); + +describe('computeDailySummaryData', () => { + it('transforms daily summary for BarChart', () => { + const days: DailySummaryDay[] = [ + { date: '2024-01-15', total_minutes: 720, day_minutes: 120, night_minutes: 600 }, + ]; + const result = computeDailySummaryData(days); + expect(result[0]).toEqual({ + date: '2024-01-15', + daySleep: 120, + nightSleep: 600, + total: 720, + }); + }); +}); + +describe('computeAverageStats', () => { + it('calculates averages from daily data', () => { + const days: DailySummaryDay[] = [ + { date: '2024-01-15', total_minutes: 720, day_minutes: 120, night_minutes: 600 }, + { date: '2024-01-16', total_minutes: 600, day_minutes: 100, night_minutes: 500 }, + ]; + const result = computeAverageStats(days); + expect(result.avgTotal).toBe(660); + expect(result.avgDay).toBe(110); + expect(result.avgNight).toBe(550); + expect(result.count).toBe(2); + }); + + it('handles empty array', () => { + const result = computeAverageStats([]); + expect(result.avgTotal).toBe(0); + expect(result.count).toBe(0); + }); +}); + +describe('reverseDateOrder', () => { + it('reverses array order', () => { + const days = [ + { date: '2024-01-16' }, + { date: '2024-01-15' }, + { date: '2024-01-14' }, + ]; + const result = reverseDateOrder(days); + expect(result[0].date).toBe('2024-01-14'); + }); +}); + +describe('filterDaysWithData', () => { + it('filters days with zero total', () => { + const days = [ + { date: '2024-01-15', total_minutes: 720 }, + { date: '2024-01-16', total_minutes: 0 }, + ]; + const result = filterDaysWithData(days); + expect(result).toHaveLength(1); + expect(result[0].date).toBe('2024-01-15'); + }); +}); \ No newline at end of file diff --git a/frontend/tests/unit/time.test.ts b/frontend/tests/unit/time.test.ts new file mode 100644 index 0000000..133bb7a --- /dev/null +++ b/frontend/tests/unit/time.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import { + formatMinutesAfter, + toLocalDatetimeInputValue, + formatElapsedTime, + clampToDay, + parseTime, + formatMinutes, + getDaysAgo, + getToday, +} from '../../src/utils/time'; + +describe('formatMinutesAfter', () => { + it('converts positive minutes after reference', () => { + expect(formatMinutesAfter(75, '04:00')).toBe('05:15'); + }); + + it('handles zero minutes', () => { + expect(formatMinutesAfter(0, '04:00')).toBe('04:00'); + }); + + it('wraps around midnight for large values', () => { + expect(formatMinutesAfter(480, '04:00')).toBe('12:00'); + }); + + it('handles null input', () => { + expect(formatMinutesAfter(null, '04:00')).toBe('N/A'); + }); + + it('handles negative minutes', () => { + expect(formatMinutesAfter(-30, '04:00')).toBe('03:30'); + }); +}); + +describe('toLocalDatetimeInputValue', () => { + it('converts Date to datetime-local format', () => { + const date = new Date('2024-01-15T21:30:00Z'); + const result = toLocalDatetimeInputValue(date); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + }); +}); + +describe('formatElapsedTime', () => { + it('formats hours and minutes', () => { + const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(); + expect(formatElapsedTime(twoHoursAgo)).toBe('2h'); + }); + + it('formats minutes only', () => { + const thirtyMinsAgo = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + expect(formatElapsedTime(thirtyMinsAgo)).toBe('30m'); + }); +}); + +describe('clampToDay', () => { + it('clamps to start of day', () => { + const date = new Date('2024-01-15T15:30:00'); + const result = clampToDay(date, 'start'); + expect(result.getHours()).toBe(0); + expect(result.getMinutes()).toBe(0); + }); + + it('clamps to end of day', () => { + const date = new Date('2024-01-15T15:30:00'); + const result = clampToDay(date, 'end'); + expect(result.getHours()).toBe(23); + expect(result.getMinutes()).toBe(59); + }); +}); + +describe('parseTime', () => { + it('parses HH:MM string to Date', () => { + const result = parseTime('07:30'); + expect(result.getHours()).toBe(7); + expect(result.getMinutes()).toBe(30); + }); +}); + +describe('formatMinutes', () => { + it('formats hours and minutes', () => { + expect(formatMinutes(90)).toBe('1h 30m'); + }); + + it('formats hours only', () => { + expect(formatMinutes(120)).toBe('2h'); + }); + + it('formats minutes only', () => { + expect(formatMinutes(45)).toBe('45m'); + }); +}); + +describe('getDaysAgo', () => { + it('returns date string for N days ago', () => { + const result = getDaysAgo(7); + expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); +}); + +describe('getToday', () => { + it('returns today date string', () => { + const result = getToday(); + const today = new Date().toISOString().split('T')[0]; + expect(result).toBe(today); + }); +}); \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..24a2fc3 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import path from 'path' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + test: { + globals: true, + environment: 'jsdom', + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/test_asgi.py b/test_asgi.py new file mode 100644 index 0000000..36b42a8 --- /dev/null +++ b/test_asgi.py @@ -0,0 +1,12 @@ +import asyncio +from httpx import AsyncClient, ASGITransport +from fastapi import FastAPI + +app = FastAPI() + +async def test(): + async with AsyncClient(transport=ASGITransport(app=app), base_url='http://test') as ac: + print('Success') + +if __name__ == '__main__': + asyncio.run(test()) diff --git a/test_db.py b/test_db.py new file mode 100644 index 0000000..45632cf --- /dev/null +++ b/test_db.py @@ -0,0 +1,16 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine + +async def test(): + # Using relative path from the current directory (root) to backend/data/sleep.db + engine = create_async_engine('sqlite+aiosqlite:///./backend/data/sleep.db') + try: + async with engine.begin() as conn: + print('Success') + except Exception as e: + print(f'Error: {e}') + finally: + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(test())