feat(family): deploy Single-Writer DuckDB backend, Mila parent cockpit & feeding analytics, and Moritz macro tracker

This commit is contained in:
Moritz Graf
2026-10-17 07:21:49 +02:00
parent ef50d87bd7
commit 5b8432caf4
96 changed files with 16409 additions and 29 deletions
+13
View File
@@ -0,0 +1,13 @@
# AGENTS.md — DuckDB Backend
## Purpose
FastAPI HTTP backend executing in-memory DuckDB queries against Git-backed JSONL data in the Knowledge Wiki.
## Development & Testing
```bash
# Run tests
uv run --project projects/duckdb-backend pytest -v projects/duckdb-backend/tests
# Run locally
uv run --project projects/duckdb-backend uvicorn app.main:app --port 8000
```
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim
# Install system dependencies (git for wiki sync)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast dependency resolution
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app
# Install dependencies
COPY pyproject.toml .
RUN uv pip install --system --no-cache -r pyproject.toml
# Copy application source
COPY app/ ./app/
ENV PYTHONUNBUFFERED=1
ENV PORT=8000
ENV HOST=0.0.0.0
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+26
View File
@@ -0,0 +1,26 @@
# DuckDB Backend
HTTP-based analytical and transactional backend for personal logging data stored in the Knowledge Wiki.
Powered by Python 3.12, FastAPI, and in-memory DuckDB querying JSONL files directly.
## Architecture
- **Single Source of Truth**: Knowledge Wiki (`/wiki` git repository with data in `/wiki/data/**/*.jsonl`).
- **Engine**: In-memory DuckDB reading `sleep`, `nutrition`, `mila`, `entities`, and `garmin` partitions.
- **Git Synchronization**:
- Background periodic `git pull --rebase origin main` (every 60s).
- Mutating operations (`POST /duckdb-api/v1/events`) append to typed JSONL partition files, sort them, commit, rebase, and push to origin main.
- Conflicts trigger automatic rebase abort and flag `git_conflict: true` on `/status` and `/healthz`.
## Endpoints
- `GET /duckdb-api/v1/mila/dashboard`: Curated aggregated dashboard for Mila (sleep, wake timer, feedings, diapers, WHO percentiles).
- `GET /duckdb-api/v1/moritz/dashboard`: Curated aggregated dashboard for Moritz (today's intake, yesterday's 24h deficit/surplus, Garmin recovery & sleep, 7d/30d trends).
- `POST /duckdb-api/v1/query`: Generic read-only SQL queries against DuckDB tables and views.
- `POST /duckdb-api/v1/events`: Record new events (sleep, nutrition, diaper, feeding, growth).
- `POST /duckdb-api/v1/events/stop`: Close active open sleep session or interval.
- `DELETE /duckdb-api/v1/events/{id}`: Soft-delete event.
- `POST /duckdb-api/v1/entities`: Upsert entities (`foods`, `recipes`, `places`, `people`).
- `GET /duckdb-api/v1/healthz`: Liveness and readiness probe.
- `GET /duckdb-api/v1/status`: Git synchronization status and table row counts.
- `POST /duckdb-api/v1/git/sync`: Manual pull or webhook sync trigger.
+1
View File
@@ -0,0 +1 @@
# app package
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import os
from pathlib import Path
def find_default_wiki_dir() -> Path:
env_wiki = os.environ.get("WIKI_DIR") or os.environ.get("GIT_REPO_DIR")
if env_wiki:
return Path(env_wiki).resolve()
candidates = [
Path("/wiki"),
Path.cwd() / "knowledge-wiki",
Path.cwd().parent.parent / "knowledge-wiki",
Path("/Users/moritz/src/infrapuzzle/knowledge-wiki"),
Path.home() / "src" / "infrapuzzle" / "knowledge-wiki",
Path("/home/node/.openclaw/wiki/main"),
]
for c in candidates:
if c.is_dir() and (c / "data").is_dir():
return c.resolve()
return (Path.cwd() / "knowledge-wiki").resolve()
WIKI_DIR = find_default_wiki_dir()
DATA_DIR = Path(
os.environ.get("DATA_DIR")
or os.environ.get("DUCKDB_DATA_DIR")
or (WIKI_DIR / "data")
).resolve()
GIT_REPO_URL = os.environ.get(
"GIT_REPO_URL",
os.environ.get("GIT_REMOTE_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git"),
)
GIT_USERNAME = os.environ.get("GIT_SYNC_USERNAME", "")
GIT_PASSWORD = os.environ.get("GIT_SYNC_PASSWORD", os.environ.get("WIKI_SYNC_TOKEN", ""))
GIT_BRANCH = os.environ.get("GIT_BRANCH", "main")
GIT_SYNC_INTERVAL_SECONDS = int(os.environ.get("GIT_SYNC_INTERVAL_SECONDS", "60"))
AUTO_GIT_SYNC = os.environ.get("AUTO_GIT_SYNC", "true").lower() in ("1", "true", "yes")
GIT_AUTHOR_NAME = os.environ.get("GIT_AUTHOR_NAME", "DuckDB Backend")
GIT_AUTHOR_EMAIL = os.environ.get("GIT_AUTHOR_EMAIL", "duckdb-backend@haumdaucher.de")
PORT = int(os.environ.get("PORT", "8000"))
HOST = os.environ.get("HOST", "0.0.0.0")
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
from __future__ import annotations
import asyncio
import datetime
import logging
import os
import subprocess
import threading
import urllib.parse
from pathlib import Path
from typing import Callable, Optional
from app.config import (
AUTO_GIT_SYNC,
GIT_AUTHOR_EMAIL,
GIT_AUTHOR_NAME,
GIT_BRANCH,
GIT_PASSWORD,
GIT_REPO_URL,
GIT_SYNC_INTERVAL_SECONDS,
GIT_USERNAME,
WIKI_DIR,
)
logger = logging.getLogger("duckdb_backend.git_sync")
class GitSyncManager:
def __init__(self, wiki_dir: Optional[Path] = None, on_data_pulled: Optional[Callable[[], None]] = None):
self.wiki_dir = wiki_dir or WIKI_DIR
self.on_data_pulled = on_data_pulled
self._lock = threading.Lock()
self.status = "ok"
self.is_conflict = False
self.last_sync_time: Optional[str] = None
self.last_commit_hash: Optional[str] = None
self.last_error: Optional[str] = None
self._bg_task: Optional[asyncio.Task] = None
self._stop_event = asyncio.Event()
def _configure_git_client(self):
"""Configures git client with safe.directory and author identity."""
try:
subprocess.run(["git", "config", "--global", "http.sslVerify", "false"], check=False)
subprocess.run(["git", "config", "--global", "--add", "safe.directory", "*"], check=False)
if (self.wiki_dir / ".git").is_dir():
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.name", GIT_AUTHOR_NAME], check=False)
subprocess.run(["git", "-C", str(self.wiki_dir), "config", "user.email", GIT_AUTHOR_EMAIL], check=False)
except Exception as e:
logger.warning(f"Failed to configure git client options: {e}")
def get_auth_repo_url(self) -> str:
if GIT_USERNAME and GIT_PASSWORD and "://" in GIT_REPO_URL:
proto, rest = GIT_REPO_URL.split("://", 1)
# Remove existing credentials in URL if any
if "@" in rest:
rest = rest.split("@", 1)[1]
user_enc = urllib.parse.quote(GIT_USERNAME, safe="")
pass_enc = urllib.parse.quote(GIT_PASSWORD, safe="")
return f"{proto}://{user_enc}:{pass_enc}@{rest}"
return GIT_REPO_URL
def ensure_repo(self) -> bool:
"""Ensures repository exists and is initialized."""
with self._lock:
self._configure_git_client()
if (self.wiki_dir / ".git").is_dir():
try:
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
self.last_commit_hash = res.stdout.strip()
return True
except Exception as e:
logger.warning(f"Failed to check git rev: {e}")
return True
if not GIT_REPO_URL:
logger.info(f"Wiki directory {self.wiki_dir} is not a git repo, but no GIT_REPO_URL configured.")
return False
logger.info(f"Initializing repository {GIT_REPO_URL} in {self.wiki_dir}...")
self.wiki_dir.mkdir(parents=True, exist_ok=True)
auth_url = self.get_auth_repo_url()
try:
# Use git init + remote + fetch + checkout to safely support non-empty directories (e.g. lost+found on PVC)
subprocess.run(["git", "-C", str(self.wiki_dir), "init"], capture_output=True, check=False)
self._configure_git_client()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "add", "origin", auth_url],
capture_output=True,
check=False,
)
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=60,
)
if res.returncode == 0:
if (self.wiki_dir / ".git" / "shallow").exists():
subprocess.run(
["git", "-C", str(self.wiki_dir), "fetch", "--unshallow", "origin", GIT_BRANCH],
capture_output=True,
check=False,
)
checkout_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "checkout", "-f", "-B", GIT_BRANCH, f"origin/{GIT_BRANCH}"],
capture_output=True,
text=True,
check=False,
)
if checkout_res.returncode == 0:
logger.info("Successfully initialized and checked out wiki repository.")
self.status = "ok"
self.is_conflict = False
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
return True
else:
self.status = "error"
self.last_error = f"Checkout failed: {checkout_res.stderr.strip()}"
logger.error(self.last_error)
return False
else:
self.status = "error"
self.last_error = f"Fetch failed: {res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Clone exception: {e}"
logger.error(self.last_error)
return False
def pull_rebase(self) -> bool:
"""Pulls latest changes via rebase. Handles conflicts cleanly."""
if not (self.wiki_dir / ".git").is_dir():
return False
with self._lock:
try:
# Update remote url with credentials if provided
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
# Fetch and rebase with autostash
res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
# Update commit hash
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
if self.on_data_pulled:
try:
self.on_data_pulled()
except Exception as ex:
logger.error(f"Error in on_data_pulled callback: {ex}")
return True
else:
# Check if conflict occurred
stderr = res.stderr or res.stdout
logger.warning(f"Git pull --rebase failed: {stderr}")
# Abort any in-progress rebase to keep working tree stable
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict: {stderr.strip()}"
return False
except Exception as e:
self.status = "error"
self.last_error = f"Pull error: {e}"
logger.error(self.last_error)
return False
def commit_and_push(self, commit_msg: str) -> bool:
"""Stages data/, commits with descriptive message, rebases and pushes."""
if not (self.wiki_dir / ".git").is_dir():
return False
with self._lock:
try:
self._configure_git_client()
data_rel = "data"
subprocess.run(
["git", "-C", str(self.wiki_dir), "add", data_rel],
capture_output=True,
check=False,
)
commit_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "commit", "-m", f"{commit_msg} [skip ci]"],
capture_output=True,
text=True,
check=False,
)
if commit_res.returncode != 0:
# Nothing to commit or already committed
logger.debug(f"Commit returncode {commit_res.returncode}: {commit_res.stdout.strip()}")
return True
# Pull rebase with autostash before push
auth_url = self.get_auth_repo_url()
subprocess.run(
["git", "-C", str(self.wiki_dir), "remote", "set-url", "origin", auth_url],
capture_output=True,
check=False,
)
rebase_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "pull", "--rebase", "--autostash", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if rebase_res.returncode != 0:
stderr = rebase_res.stderr or rebase_res.stdout
logger.warning(f"Rebase failed during commit_and_push: {stderr}")
subprocess.run(
["git", "-C", str(self.wiki_dir), "rebase", "--abort"],
capture_output=True,
check=False,
)
self.is_conflict = True
self.status = "conflict"
self.last_error = f"Rebase conflict on push: {stderr.strip()}"
return False
# Push to remote
push_res = subprocess.run(
["git", "-C", str(self.wiki_dir), "push", "origin", GIT_BRANCH],
capture_output=True,
text=True,
check=False,
timeout=30,
)
if push_res.returncode == 0:
self.status = "ok"
self.is_conflict = False
self.last_error = None
self.last_sync_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
rev = subprocess.run(
["git", "-C", str(self.wiki_dir), "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if rev.returncode == 0:
self.last_commit_hash = rev.stdout.strip()
return True
else:
self.status = "error"
self.last_error = f"Push error: {push_res.stderr.strip()}"
logger.error(self.last_error)
return False
except Exception as e:
self.status = "error"
self.last_error = f"Commit/push error: {e}"
logger.error(self.last_error)
return False
async def start_periodic_sync(self):
"""Starts background periodic git pull loop."""
if not AUTO_GIT_SYNC:
logger.info("Automatic git sync is disabled by configuration.")
return
self._stop_event.clear()
async def _loop():
logger.info(f"Starting periodic git sync every {GIT_SYNC_INTERVAL_SECONDS}s")
while not self._stop_event.is_set():
try:
await asyncio.to_thread(self.pull_rebase)
except Exception as ex:
logger.error(f"Error during periodic git pull: {ex}")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=GIT_SYNC_INTERVAL_SECONDS)
except asyncio.TimeoutError:
pass
self._bg_task = asyncio.create_task(_loop())
async def stop_periodic_sync(self):
"""Stops background sync loop."""
self._stop_event.set()
if self._bg_task:
self._bg_task.cancel()
try:
await self._bg_task
except asyncio.CancelledError:
pass
self._bg_task = None
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import DATA_DIR, WIKI_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.routes.dashboard import router as dashboard_router
from app.routes.entities import router as entities_router
from app.routes.events import router as events_router
from app.routes.query import router as query_router
from app.routes.system import router as system_router
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("duckdb_backend")
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("Initializing DuckDB Backend...")
git_sync = GitSyncManager(
wiki_dir=WIKI_DIR,
on_data_pulled=lambda: app.state.engine.reload_all() if hasattr(app.state, "engine") else None,
)
git_sync.ensure_repo()
app.state.git_sync = git_sync
engine = DuckDBEngine(data_dir=DATA_DIR, git_sync_manager=git_sync)
app.state.engine = engine
await git_sync.start_periodic_sync()
logger.info("DuckDB Backend is ready to serve queries.")
yield
logger.info("Shutting down DuckDB Backend...")
await git_sync.stop_periodic_sync()
app = FastAPI(
title="DuckDB Backend for Knowledge Wiki",
version="1.0.0",
description="HTTP-based analytical and transactional backend for personal logging data powered by DuckDB",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API v1 prefix
v1_router = FastAPI()
app.include_router(dashboard_router, prefix="/duckdb-api/v1")
app.include_router(query_router, prefix="/duckdb-api/v1")
app.include_router(events_router, prefix="/duckdb-api/v1")
app.include_router(entities_router, prefix="/duckdb-api/v1")
app.include_router(system_router, prefix="/duckdb-api/v1")
# Direct root health probes for Kubernetes
app.include_router(system_router)
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
class QueryRequest(BaseModel):
query: str = Field(..., description="Read-only SQL query to execute in DuckDB")
params: Optional[List[Any]] = Field(default=None, description="Positional query parameters")
class QueryResponse(BaseModel):
columns: List[str]
rows: List[Dict[str, Any]]
count: int
execution_time_ms: float
class EventCreateRequest(BaseModel):
kind: str = Field(..., description="Event kind, e.g. sleep.session, nutrition.intake, diaper.change, feeding.bottle, growth.measurement")
subject: str = Field(..., description="Event subject, e.g. mila or moritz")
payload: Dict[str, Any] = Field(default_factory=dict, description="Event-specific payload")
actor: Optional[str] = Field(default="web-app", description="Actor who logged the event")
date: Optional[str] = Field(default=None, description="Explicit log date override YYYY-MM-DD")
start: Optional[str] = Field(default=None, description="ISO-8601 start timestamp")
end: Optional[str] = Field(default=None, description="ISO-8601 end timestamp (optional)")
since: Optional[str] = Field(default=None, description="Relative start duration like '15m', '1h'")
estimated: bool = Field(default=False, description="Whether intake is estimated")
meal: Optional[str] = Field(default=None, description="Meal category, e.g. fruehstueck, mittagessen, abendessen, snack")
note: Optional[str] = Field(default=None, description="Free-text note")
source_ref: Optional[str] = Field(default=None, description="Source reference")
allow_duplicate: bool = Field(default=False, description="Allow event within 15min of duplicate")
class EventStopRequest(BaseModel):
kind: str = Field(default="sleep.session", description="Event kind to stop")
subject: str = Field(..., description="Event subject, e.g. mila")
end_time: Optional[str] = Field(default=None, description="ISO-8601 end timestamp")
since: Optional[str] = Field(default=None, description="Relative end duration like '5m'")
event_id: Optional[str] = Field(default=None, description="Specific event ID to close")
actor: Optional[str] = Field(default="web-app", description="Actor who stopped the event")
class EntityPutRequest(BaseModel):
collection: str = Field(..., description="Target collection: foods, recipes, places, people")
record: Dict[str, Any] = Field(..., description="Entity record with 'id'")
class GarminSyncRequest(BaseModel):
daily: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin daily summary records")
sleep: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin sleep records")
activities: List[Dict[str, Any]] = Field(default_factory=list, description="Garmin activity records")
class StatusResponse(BaseModel):
status: str
git_conflict: bool
last_sync_time: Optional[str]
last_commit_hash: Optional[str]
last_error: Optional[str]
data_dir: str
table_counts: Dict[str, int]
@@ -0,0 +1 @@
# routes package
@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
router = APIRouter(tags=["Dashboards"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.get("/mila/dashboard")
async def get_mila_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/dashboard")
async def get_moritz_dashboard(
as_of: Optional[str] = Query(default=None, description="Optional target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_dashboard(as_of_date=as_of)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/mila/day/{date}")
async def get_mila_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_mila_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/moritz/day/{date}")
async def get_moritz_day_detail(
date: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_moritz_day_detail(target_date=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/summary/{subject}")
async def get_summary(
subject: str,
date: Optional[str] = Query(default=None, description="Target date YYYY-MM-DD"),
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.get_summary(subject=subject, date_str=date)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,51 @@
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.engine import DuckDBEngine
from app.models import EntityPutRequest
router = APIRouter(tags=["Entities"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/entities")
async def put_entity(
req: EntityPutRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.put_entity(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/entities/{collection}")
async def list_entities(
collection: str,
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.list_entities(collection=collection)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/places/near")
async def find_places_near(
lat: float = Query(..., description="Latitude"),
lon: float = Query(..., description="Longitude"),
radius_m: float = Query(default=1000.0, description="Radius in meters"),
category: Optional[str] = Query(default=None, description="Category filter"),
engine: DuckDBEngine = Depends(get_engine),
) -> List[Dict[str, Any]]:
try:
return engine.find_places_near(lat=lat, lon=lon, radius_m=radius_m, category=category)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest, GarminSyncRequest
router = APIRouter(tags=["Events"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/events")
async def add_event(
req: EventCreateRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.add_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/events/stop")
async def stop_event(
req: EventStopRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.stop_event(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/events/{event_id}")
async def delete_event(
event_id: str,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.delete_event(event_id)
except ValueError as ve:
raise HTTPException(status_code=404, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/garmin/sync")
async def sync_garmin(
req: GarminSyncRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
try:
return engine.sync_garmin(req)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,35 @@
from __future__ import annotations
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from app.engine import DuckDBEngine
from app.models import QueryRequest, QueryResponse
router = APIRouter(tags=["Generic Queries"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
@router.post("/query", response_model=QueryResponse)
async def execute_query(
req: QueryRequest,
engine: DuckDBEngine = Depends(get_engine),
) -> QueryResponse:
t0 = time.perf_counter()
try:
rows = engine.query(req.query, req.params)
duration_ms = round((time.perf_counter() - t0) * 1000, 2)
cols = list(rows[0].keys()) if rows else []
return QueryResponse(
columns=cols,
rows=rows,
count=len(rows),
execution_time_ms=duration_ms,
)
except ValueError as ve:
raise HTTPException(status_code=400, detail=str(ve))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@@ -0,0 +1,77 @@
from __future__ import annotations
from typing import Any, Dict
from fastapi import APIRouter, Depends, HTTPException, Request
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.models import StatusResponse
router = APIRouter(tags=["System"])
def get_engine(request: Request) -> DuckDBEngine:
return request.app.state.engine
def get_git_sync(request: Request) -> Optional[GitSyncManager]:
return getattr(request.app.state, "git_sync", None)
@router.get("/healthz")
async def healthz(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
conflict = git_sync.is_conflict if git_sync else False
status_str = "conflict" if conflict else "ok"
return {
"status": status_str,
"conflict": conflict,
"engine_ready": True,
}
@router.get("/status", response_model=StatusResponse)
async def status(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> StatusResponse:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
counts = engine.get_table_counts()
return StatusResponse(
status=git_sync.status if git_sync else "ok",
git_conflict=git_sync.is_conflict if git_sync else False,
last_sync_time=git_sync.last_sync_time if git_sync else None,
last_commit_hash=git_sync.last_commit_hash if git_sync else None,
last_error=git_sync.last_error if git_sync else None,
data_dir=str(engine.data_dir),
table_counts=counts,
)
@router.post("/git/sync")
async def trigger_sync(
request: Request,
engine: DuckDBEngine = Depends(get_engine),
) -> Dict[str, Any]:
git_sync: Optional[GitSyncManager] = get_git_sync(request)
if not git_sync:
raise HTTPException(status_code=400, detail="Git sync is not configured")
success = git_sync.pull_rebase()
if success:
engine.reload_all()
return {
"status": "ok",
"message": "Git pull --rebase completed and DuckDB reloaded.",
"commit": git_sync.last_commit_hash,
}
else:
return {
"status": git_sync.status,
"conflict": git_sync.is_conflict,
"error": git_sync.last_error,
}
+128
View File
@@ -0,0 +1,128 @@
from __future__ import annotations
import math
from typing import Any, Dict, List, Optional, Tuple
WHO_GIRLS_WEIGHT = [
{"month": 0, "L": 0.3809, "M": 3.2322, "S": 0.14171, "p3": 2.4, "p50": 3.2, "p97": 4.2},
{"month": 1, "L": 0.1714, "M": 4.1873, "S": 0.13724, "p3": 3.2, "p50": 4.2, "p97": 5.4},
{"month": 2, "L": 0.0962, "M": 5.1282, "S": 0.13000, "p3": 4.0, "p50": 5.1, "p97": 6.5},
{"month": 3, "L": 0.0402, "M": 5.8458, "S": 0.12619, "p3": 4.6, "p50": 5.8, "p97": 7.4},
{"month": 4, "L": -0.0050, "M": 6.4237, "S": 0.12402, "p3": 5.1, "p50": 6.4, "p97": 8.1},
{"month": 5, "L": -0.0430, "M": 6.8985, "S": 0.12274, "p3": 5.5, "p50": 6.9, "p97": 8.7},
{"month": 6, "L": -0.0756, "M": 7.2970, "S": 0.12204, "p3": 5.8, "p50": 7.3, "p97": 9.2},
{"month": 7, "L": -0.1039, "M": 7.6422, "S": 0.12178, "p3": 6.1, "p50": 7.6, "p97": 9.6},
{"month": 8, "L": -0.1288, "M": 7.9487, "S": 0.12181, "p3": 6.3, "p50": 7.9, "p97": 10.0},
{"month": 9, "L": -0.1507, "M": 8.2254, "S": 0.12199, "p3": 6.6, "p50": 8.2, "p97": 10.4},
{"month": 10, "L": -0.1700, "M": 8.4800, "S": 0.12223, "p3": 6.8, "p50": 8.5, "p97": 10.7},
{"month": 11, "L": -0.1872, "M": 8.7192, "S": 0.12247, "p3": 7.0, "p50": 8.7, "p97": 11.0},
{"month": 12, "L": -0.2024, "M": 8.9481, "S": 0.12268, "p3": 7.1, "p50": 8.9, "p97": 11.3},
{"month": 13, "L": -0.2158, "M": 9.1699, "S": 0.12283, "p3": 7.3, "p50": 9.2, "p97": 11.6},
{"month": 14, "L": -0.2278, "M": 9.3870, "S": 0.12294, "p3": 7.5, "p50": 9.4, "p97": 11.9},
{"month": 15, "L": -0.2384, "M": 9.6008, "S": 0.12299, "p3": 7.7, "p50": 9.6, "p97": 12.2},
{"month": 16, "L": -0.2478, "M": 9.8124, "S": 0.12303, "p3": 7.8, "p50": 9.8, "p97": 12.5},
{"month": 17, "L": -0.2562, "M": 10.0226, "S": 0.12306, "p3": 8.0, "p50": 10.0, "p97": 12.7},
{"month": 18, "L": -0.2637, "M": 10.2315, "S": 0.12309, "p3": 8.2, "p50": 10.2, "p97": 13.0},
{"month": 19, "L": -0.2703, "M": 10.4393, "S": 0.12315, "p3": 8.3, "p50": 10.4, "p97": 13.3},
{"month": 20, "L": -0.2762, "M": 10.6464, "S": 0.12323, "p3": 8.5, "p50": 10.6, "p97": 13.5},
{"month": 21, "L": -0.2815, "M": 10.8534, "S": 0.12335, "p3": 8.7, "p50": 10.9, "p97": 13.8},
{"month": 22, "L": -0.2862, "M": 11.0608, "S": 0.12350, "p3": 8.8, "p50": 11.1, "p97": 14.1},
{"month": 23, "L": -0.2903, "M": 11.2688, "S": 0.12369, "p3": 9.0, "p50": 11.3, "p97": 14.3},
{"month": 24, "L": -0.2941, "M": 11.4775, "S": 0.12390, "p3": 9.2, "p50": 11.5, "p97": 14.6},
]
WHO_GIRLS_HEIGHT = [
{"month": 0, "L": 1.0, "M": 49.1477, "S": 0.03790, "p3": 45.6, "p50": 49.1, "p97": 52.7},
{"month": 1, "L": 1.0, "M": 53.6872, "S": 0.03640, "p3": 50.0, "p50": 53.7, "p97": 57.4},
{"month": 2, "L": 1.0, "M": 57.0673, "S": 0.03568, "p3": 53.2, "p50": 57.1, "p97": 60.9},
{"month": 3, "L": 1.0, "M": 59.8029, "S": 0.03520, "p3": 55.8, "p50": 59.8, "p97": 63.8},
{"month": 4, "L": 1.0, "M": 62.0899, "S": 0.03486, "p3": 58.0, "p50": 62.1, "p97": 66.2},
{"month": 5, "L": 1.0, "M": 64.0301, "S": 0.03463, "p3": 59.9, "p50": 64.0, "p97": 68.2},
{"month": 6, "L": 1.0, "M": 65.7311, "S": 0.03448, "p3": 61.5, "p50": 65.7, "p97": 70.0},
{"month": 7, "L": 1.0, "M": 67.2873, "S": 0.03441, "p3": 62.9, "p50": 67.3, "p97": 71.6},
{"month": 8, "L": 1.0, "M": 68.7498, "S": 0.03440, "p3": 64.3, "p50": 68.7, "p97": 73.2},
{"month": 9, "L": 1.0, "M": 70.1435, "S": 0.03444, "p3": 65.6, "p50": 70.1, "p97": 74.7},
{"month": 10, "L": 1.0, "M": 71.4818, "S": 0.03452, "p3": 66.8, "p50": 71.5, "p97": 76.1},
{"month": 11, "L": 1.0, "M": 72.7710, "S": 0.03464, "p3": 68.0, "p50": 72.8, "p97": 77.5},
{"month": 12, "L": 1.0, "M": 74.0150, "S": 0.03479, "p3": 69.2, "p50": 74.0, "p97": 78.9},
{"month": 13, "L": 1.0, "M": 75.2176, "S": 0.03496, "p3": 70.3, "p50": 75.2, "p97": 80.2},
{"month": 14, "L": 1.0, "M": 76.3817, "S": 0.03514, "p3": 71.3, "p50": 76.4, "p97": 81.4},
{"month": 15, "L": 1.0, "M": 77.5099, "S": 0.03534, "p3": 72.4, "p50": 77.5, "p97": 82.7},
{"month": 16, "L": 1.0, "M": 78.6055, "S": 0.03555, "p3": 73.3, "p50": 78.6, "p97": 83.9},
{"month": 17, "L": 1.0, "M": 79.6710, "S": 0.03576, "p3": 74.3, "p50": 79.7, "p97": 85.0},
{"month": 18, "L": 1.0, "M": 80.7079, "S": 0.03598, "p3": 75.2, "p50": 80.7, "p97": 86.2},
{"month": 19, "L": 1.0, "M": 81.7182, "S": 0.03620, "p3": 76.2, "p50": 81.7, "p97": 87.3},
{"month": 20, "L": 1.0, "M": 82.7036, "S": 0.03643, "p3": 77.0, "p50": 82.7, "p97": 88.4},
{"month": 21, "L": 1.0, "M": 83.6654, "S": 0.03666, "p3": 77.9, "p50": 83.7, "p97": 89.4},
{"month": 22, "L": 1.0, "M": 84.6040, "S": 0.03688, "p3": 78.7, "p50": 84.6, "p97": 90.5},
{"month": 23, "L": 1.0, "M": 85.5202, "S": 0.03711, "p3": 79.6, "p50": 85.5, "p97": 91.5},
{"month": 24, "L": 1.0, "M": 86.4153, "S": 0.03734, "p3": 80.3, "p50": 86.4, "p97": 92.5},
]
def _interpolate_lms(table: List[Dict[str, float]], age_months: float) -> Tuple[float, float, float, float, float, float]:
m_clamped = max(0.0, min(float(table[-1]["month"]), float(age_months)))
m_low = int(math.floor(m_clamped))
m_high = int(math.ceil(m_clamped))
r_low = table[m_low]
if m_low == m_high:
return r_low["L"], r_low["M"], r_low["S"], r_low["p3"], r_low["p50"], r_low["p97"]
r_high = table[m_high]
frac = m_clamped - m_low
L = r_low["L"] + frac * (r_high["L"] - r_low["L"])
M = r_low["M"] + frac * (r_high["M"] - r_low["M"])
S = r_low["S"] + frac * (r_high["S"] - r_low["S"])
p3 = r_low["p3"] + frac * (r_high["p3"] - r_low["p3"])
p50 = r_low["p50"] + frac * (r_high["p50"] - r_low["p50"])
p97 = r_low["p97"] + frac * (r_high["p97"] - r_low["p97"])
return L, M, S, p3, p50, p97
def calculate_percentile(metric: str, value: float, age_months: float) -> Dict[str, Any]:
table = WHO_GIRLS_WEIGHT if metric == "weight" else WHO_GIRLS_HEIGHT
L, M, S, p3, p50, p97 = _interpolate_lms(table, age_months)
if value <= 0 or M <= 0 or S <= 0:
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": 0.0,
"percentile": 50.0,
"percentile_str": "P50",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": True,
"status": "normal",
}
if abs(L) > 1e-6:
z = ((value / M) ** L - 1.0) / (L * S)
else:
z = math.log(value / M) / S
p = 0.5 * (1.0 + math.erf(z / math.sqrt(2.0))) * 100.0
p_clamped = max(0.1, min(99.9, p))
in_normal = 3.0 <= p_clamped <= 97.0
if p_clamped < 3.0:
status = "unter P3"
elif p_clamped > 97.0:
status = "über P97"
else:
status = "im WHO-Normbereich"
return {
"value": value,
"age_months": round(age_months, 2),
"z_score": round(z, 2),
"percentile": round(p_clamped, 1),
"percentile_str": f"P{int(round(p_clamped))}",
"p3": round(p3, 2),
"p50": round(p50, 2),
"p97": round(p97, 2),
"in_normal_range": in_normal,
"status": status,
}
+28
View File
@@ -0,0 +1,28 @@
[project]
name = "duckdb-backend"
version = "1.0.0"
description = "FastAPI HTTP DuckDB Backend for Knowledge Wiki Personal Data"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"duckdb>=1.1.0",
"python-ulid>=3.0.0",
"pytz>=2024.1",
"pydantic>=2.8.0",
"httpx>=0.27.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]
+35
View File
@@ -0,0 +1,35 @@
import os
import shutil
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.config import DATA_DIR
from app.engine import DuckDBEngine
from app.git_sync import GitSyncManager
from app.main import app
@pytest.fixture(scope="session")
def test_data_dir():
# Use real wiki data dir if exists, else temporary copy
if DATA_DIR.is_dir():
yield DATA_DIR
else:
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.fixture
def engine(test_data_dir):
return DuckDBEngine(data_dir=test_data_dir)
@pytest.fixture
def client(engine):
app.state.engine = engine
app.state.git_sync = GitSyncManager(wiki_dir=engine.data_dir.parent)
with TestClient(app) as c:
yield c
@@ -0,0 +1,179 @@
import pytest
from fastapi.testclient import TestClient
def test_mila_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "growth" in data
def test_moritz_dashboard_endpoint(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "seven_days" in data
assert "thirty_days" in data
def test_healthz_and_status_endpoints(client: TestClient):
resp_h = client.get("/healthz")
assert resp_h.status_code == 200
assert resp_h.json()["engine_ready"] is True
resp_s = client.get("/duckdb-api/v1/status")
assert resp_s.status_code == 200
data_s = resp_s.json()
assert "status" in data_s
assert "table_counts" in data_s
assert data_s["table_counts"]["sleep"] > 0
def test_mila_history_includes_diaper_only_days(client: TestClient):
# Log a diaper change for a specific day without sleep
client.post("/duckdb-api/v1/events", json={
"kind": "diaper.change",
"subject": "mila",
"start": "2026-10-12T12:00:00+02:00",
"payload": {"pee": True, "poo": True},
})
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
history = data["history"]
dates = [h["date"] for h in history]
assert "2026-10-12" in dates
def test_moritz_seven_days_includes_garmin_days(client: TestClient):
resp = client.get("/duckdb-api/v1/moritz/dashboard")
assert resp.status_code == 200
data = resp.json()
days = data["seven_days"]["days"]
assert len(days) > 0
# Every returned day has a valid date and steps or expenditure
for d in days:
assert d["date"] is not None
assert "expenditure_kcal" in d
def test_mila_dashboard_parent_cockpit_and_norms(client: TestClient):
resp = client.get("/duckdb-api/v1/mila/dashboard")
assert resp.status_code == 200
data = resp.json()
# Parent cockpit live fields
live = data["live_status"]
assert "is_sleeping" in live
assert "active_sleep_duration_str" in live
assert "wake_window_minutes" in live
assert "last_feed_since_str" in live
assert "last_diaper_since_str" in live
assert "last_stool_str" in live
# Science age norms
assert "age_norms" in data
norms = data["age_norms"]
assert norms["age_months"] >= 6.0
assert norms["wake_window_min_minutes"] == 135
assert norms["wake_window_max_minutes"] == 180
assert norms["feed_interval_min_minutes"] == 180
# Ensure IDs on items
today = data["today"]
for f in today["feedings"]:
assert "id" in f
for d in today["diapers"]:
assert "id" in d
def test_day_detail_and_summary_endpoints(client: TestClient):
# Test mila day detail
resp_mila = client.get("/duckdb-api/v1/mila/day/2026-10-12")
assert resp_mila.status_code == 200
assert resp_mila.json()["metadata"]["today_date"] == "2026-10-12"
# Test moritz day detail
resp_moritz = client.get("/duckdb-api/v1/moritz/day/2026-10-12")
assert resp_moritz.status_code == 200
assert "today" in resp_moritz.json()
# Test summary
resp_sum = client.get("/duckdb-api/v1/summary/mila?date=2026-10-12")
assert resp_sum.status_code == 200
sum_data = resp_sum.json()
assert sum_data["subject"] == "mila"
assert sum_data["date"] == "2026-10-12"
assert "nutrition" in sum_data
def test_entities_and_places_endpoints(client: TestClient):
# Put and list entity
client.post("/duckdb-api/v1/entities", json={
"collection": "places",
"record": {
"id": "test-biergarten",
"name": "Test Biergarten",
"category": "biergarten",
"lat": 49.02,
"lon": 12.09,
}
})
resp_places = client.get("/duckdb-api/v1/entities/places")
assert resp_places.status_code == 200
places = resp_places.json()
assert any(p["id"] == "test-biergarten" for p in places)
# Places near search
resp_near = client.get("/duckdb-api/v1/places/near?lat=49.02&lon=12.09&radius_m=500")
assert resp_near.status_code == 200
near = resp_near.json()
assert len(near) > 0
assert near[0]["id"] == "test-biergarten"
def test_garmin_sync_and_duplicate_guard(client: TestClient):
# Duplicate guard test
post_data = {
"kind": "feeding.bottle",
"subject": "mila",
"start": "2026-10-12T10:00:00+02:00",
"payload": {"consumed_ml": 180, "milk_type": "pre"},
}
r1 = client.post("/duckdb-api/v1/events", json=post_data)
assert r1.status_code == 200
assert r1.json()["status"] == "success"
assert "feedback" in r1.json()
# Second post within 15min should be duplicate_skipped
r2 = client.post("/duckdb-api/v1/events", json=post_data)
assert r2.status_code == 200
assert r2.json()["status"] == "duplicate_skipped"
# Garmin sync test
garmin_payload = {
"daily": [{
"calendarDate": "2026-10-15",
"totalKilocalories": 2400,
"activeKilocalories": 600,
"bmrKilocalories": 1800,
"totalSteps": 11000,
"restingHeartRate": 52,
}],
"sleep": [],
"activities": [],
}
r_sync = client.post("/duckdb-api/v1/garmin/sync", json=garmin_payload)
assert r_sync.status_code == 200
assert r_sync.json()["status"] == "success"
assert r_sync.json()["synced_daily"] == 1
@@ -0,0 +1,129 @@
import tempfile
import pytest
from pathlib import Path
from fastapi.testclient import TestClient
from app.engine import DuckDBEngine
from app.models import EventCreateRequest, EventStopRequest
def test_add_and_stop_sleep_event(tmp_path: Path):
# Use isolated temp directory for write tests so we do not pollute wiki git repo
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Add active sleep event (end=None)
add_req = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="test-runner",
note="Mittagsschlaf Test",
)
res_add = engine.add_event(add_req)
assert res_add["status"] == "success"
event_id = res_add["id"]
assert event_id is not None
# Check that it appears in open sleep
open_rows = engine.query(f"SELECT * FROM sleep WHERE id = '{event_id}' AND (isOpen = true OR endIso IS NULL)")
assert len(open_rows) == 1
# 2. Stop the active sleep event
stop_req = EventStopRequest(
kind="sleep.session",
subject="mila",
event_id=event_id,
actor="test-runner",
)
res_stop = engine.stop_event(stop_req)
assert res_stop["status"] == "success"
assert res_stop["id"] == event_id
# Check that it is now closed
closed_rows = engine.query(f"SELECT isOpen, endIso FROM sleep WHERE id = '{event_id}'")
assert len(closed_rows) == 1
assert closed_rows[0]["isOpen"] is False
assert closed_rows[0]["endIso"] is not None
def test_add_nutrition_and_diaper_event(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# Add nutrition event
nut_req = EventCreateRequest(
kind="nutrition.intake",
subject="moritz",
payload={"item": "Apfel", "qty": 150.0, "kcal": 78.0, "protein_g": 0.5},
meal="snack",
actor="test-runner",
)
res_nut = engine.add_event(nut_req)
assert res_nut["status"] == "success"
nut_id = res_nut["id"]
nut_rows = engine.query(f"SELECT * FROM nutrition WHERE id = '{nut_id}'")
assert len(nut_rows) == 1
# Add diaper event
diaper_req = EventCreateRequest(
kind="diaper.change",
subject="mila",
payload={"pee": True, "poo": False},
actor="test-runner",
)
res_diaper = engine.add_event(diaper_req)
assert res_diaper["status"] == "success"
diaper_id = res_diaper["id"]
diaper_rows = engine.query(f"SELECT * FROM mila WHERE id = '{diaper_id}'")
assert len(diaper_rows) == 1
def test_duplicate_open_sleep_rejected(tmp_path: Path):
engine = DuckDBEngine(data_dir=tmp_path)
# 1. Start first open sleep session
req1 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "bett"},
actor="parent",
)
res1 = engine.add_event(req1)
assert res1["status"] == "success"
# 2. Attempt to start second open sleep session without stopping the first
req2 = EventCreateRequest(
kind="sleep.session",
subject="mila",
payload={"type": "nap", "location": "kinderwagen"},
actor="parent",
)
with pytest.raises(ValueError, match="already active for subject 'mila'"):
engine.add_event(req2)
def test_duplicate_open_sleep_api_endpoint(client: TestClient):
# 1. Start first session via API
res1 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res1.status_code == 200
# 2. Start second session via API -> must return 400 Bad Request
res2 = client.post("/duckdb-api/v1/events", json={
"kind": "sleep.session",
"subject": "test_baby",
"payload": {"type": "nap"},
})
assert res2.status_code == 400
assert "already active for subject 'test_baby'" in res2.json()["detail"]
# 3. Clean up by stopping
stop_res = client.post("/duckdb-api/v1/events/stop", json={
"kind": "sleep.session",
"subject": "test_baby",
})
assert stop_res.status_code == 200
@@ -0,0 +1,28 @@
import pytest
from fastapi.testclient import TestClient
def test_generic_query_select(client: TestClient):
payload = {
"query": "SELECT count(*) AS cnt FROM v_sleep WHERE subject = 'mila'"
}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 200
data = resp.json()
assert "rows" in data
assert len(data["rows"]) == 1
assert data["rows"][0]["cnt"] > 0
assert data["execution_time_ms"] < 50.0 # verifies sub-50ms execution
def test_generic_query_disallowed_drop(client: TestClient):
payload = {"query": "DROP TABLE sleep;"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
assert "not permitted" in resp.json()["detail"]
def test_generic_query_disallowed_insert(client: TestClient):
payload = {"query": "INSERT INTO sleep VALUES ('x', 'y')"}
resp = client.post("/duckdb-api/v1/query", json=payload)
assert resp.status_code == 400
@@ -0,0 +1,36 @@
import pytest
from app.engine import DuckDBEngine
def test_engine_init_and_counts(engine: DuckDBEngine):
counts = engine.get_table_counts()
assert "sleep" in counts
assert "nutrition" in counts
assert "mila" in counts
assert "garmin_daily" in counts
assert counts["sleep"] > 0
assert counts["nutrition"] > 0
def test_mila_dashboard_payload(engine: DuckDBEngine):
data = engine.get_mila_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "mila"
assert "live_status" in data
assert "today" in data
assert "averages" in data
assert "history" in data
assert "growth" in data
assert len(data["history"]) > 0
def test_moritz_dashboard_payload(engine: DuckDBEngine):
data = engine.get_moritz_dashboard()
assert data["status"] == "ok"
assert data["metadata"]["subject"] == "moritz"
assert "today" in data
assert "yesterday" in data
assert "garmin_recovery" in data
assert "seven_days" in data
assert "thirty_days" in data
assert len(data["thirty_days"]["days"]) > 0
@@ -0,0 +1,68 @@
import subprocess
from pathlib import Path
from app.git_sync import GitSyncManager
def test_git_sync_conflict_handling(tmp_path: Path):
repo_dir = tmp_path / "fake_repo"
repo_dir.mkdir()
subprocess.run(["git", "init", "-b", "main", str(repo_dir)], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.email", "test@test.local"], check=True)
subprocess.run(["git", "-C", str(repo_dir), "config", "user.name", "Test User"], check=True)
test_file = repo_dir / "test.txt"
test_file.write_text("initial")
subprocess.run(["git", "-C", str(repo_dir), "add", "."], check=True)
subprocess.run(["git", "-C", str(repo_dir), "commit", "-m", "initial commit"], check=True)
mgr = GitSyncManager(wiki_dir=repo_dir)
assert mgr.ensure_repo() is True
assert mgr.status == "ok"
assert mgr.is_conflict is False
def test_git_url_encoding(monkeypatch):
monkeypatch.setattr("app.git_sync.GIT_USERNAME", "moritz@graf")
monkeypatch.setattr("app.git_sync.GIT_PASSWORD", "p@ss:w/ord%123")
monkeypatch.setattr("app.git_sync.GIT_REPO_URL", "https://git.moritzgraf.de/moritz/knowledge-wiki.git")
mgr = GitSyncManager()
auth_url = mgr.get_auth_repo_url()
assert auth_url == "https://moritz%40graf:p%40ss%3Aw%2Ford%25123@git.moritzgraf.de/moritz/knowledge-wiki.git"
def test_ensure_repo_handles_non_empty_dir(tmp_path: Path):
# Simulate a PVC containing lost+found before repo initialization
wiki_pvc = tmp_path / "wiki_pvc"
wiki_pvc.mkdir()
(wiki_pvc / "lost+found").mkdir()
# Create upstream origin to fetch from
origin = tmp_path / "origin.git"
subprocess.run(["git", "init", "--bare", str(origin)], check=True)
# Push initial commit to bare origin
work = tmp_path / "work"
work.mkdir()
subprocess.run(["git", "init", "-b", "main", str(work)], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.name", "Admin"], check=True)
subprocess.run(["git", "-C", str(work), "config", "user.email", "admin@test.local"], check=True)
(work / "README.md").write_text("# Knowledge Wiki")
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
subprocess.run(["git", "-C", str(work), "commit", "-m", "init"], check=True)
subprocess.run(["git", "-C", str(work), "remote", "add", "origin", str(origin)], check=True)
subprocess.run(["git", "-C", str(work), "push", "origin", "main"], check=True)
# Now verify GitSyncManager initializes cleanly into non-empty directory
mgr = GitSyncManager(wiki_dir=wiki_pvc)
# Point URL to file repo
import app.git_sync
app.git_sync.GIT_REPO_URL = str(origin)
app.git_sync.GIT_USERNAME = ""
app.git_sync.GIT_PASSWORD = ""
success = mgr.ensure_repo()
assert success is True
assert (wiki_pvc / ".git").is_dir()
assert (wiki_pvc / "README.md").is_file()
assert (wiki_pvc / "lost+found").is_dir()
+813
View File
@@ -0,0 +1,813 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[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 = "duckdb"
version = "1.5.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/0b/d65ea3be00ea79aa276a8388bec588a9cbf409ce637c6d306e5316210d15/duckdb-1.5.6.tar.gz", hash = "sha256:166a91dbfacfc0c9f08cc76c0243cb6d3d4296bfab5bad72a3cfb63140a5b7c8", size = 18032957, upload-time = "2026-09-28T13:38:37.978Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/d5/d0ab77a0a1702a43171c93874f44c1f6481e30038bd3987df0d77a16a5c6/duckdb-1.5.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:48d07d0651aaeac2c3974afd37599970154b7b79b54c18f27c319c14ccf98d9d", size = 32810486, upload-time = "2026-09-28T13:37:47.254Z" },
{ url = "https://files.pythonhosted.org/packages/9f/cd/b22201de5377faa3be6c38d5f3eaa504cb480392a448bed6a4d2239469b4/duckdb-1.5.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79de3dfa8705b1ba0d59e7e3252e40ff399e0afd12f485502a6c7bf7c2fd809a", size = 17405278, upload-time = "2026-09-28T13:37:50.135Z" },
{ url = "https://files.pythonhosted.org/packages/9c/6d/f9cfb1493bbdc2f095693a402e42dce1192077f9e11573f00baed6a748de/duckdb-1.5.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcccce20965e6986cd083fdf192c461685ad0b93cd1ccd0b2a8207f1185f078b", size = 15532943, upload-time = "2026-09-28T13:37:52.927Z" },
{ url = "https://files.pythonhosted.org/packages/53/04/f65ccfaa5a833f2e570c4a140f03c8f95da416da9fe8ed08401f81f8242a/duckdb-1.5.6-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce89a1025a5317ebe9c520876c48032b5247ac574865486648b1a004f6009875", size = 19454940, upload-time = "2026-09-28T13:37:55.732Z" },
{ url = "https://files.pythonhosted.org/packages/4c/99/be75c788a492f8d77b7a1cdc1b19939ae7be0007f2028691ad371a1a33ee/duckdb-1.5.6-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bc9619ed7d4ffa117b5155d84b44794366bb6635178d78ed5e13a6024845c757", size = 21568087, upload-time = "2026-09-28T13:37:58.191Z" },
{ url = "https://files.pythonhosted.org/packages/b5/95/889f8508960e47c0a7c75cc5bf57cde8512fc24f8db7b3129cca5388da42/duckdb-1.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:09ff51b230219f0d8b47fc8a1e17fb595ba9fab0c3d96a6de4d00b8ff86b3cf1", size = 13190189, upload-time = "2026-09-28T13:38:00.407Z" },
{ url = "https://files.pythonhosted.org/packages/a4/c9/baab503364a68309f8368c88e77f5341e7d94927bdf3e6d703f0e5035f3e/duckdb-1.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:b8d795c8b2d5634b3269f974aa97f1fdf878f62f032317a52252a151b693fb1e", size = 14021977, upload-time = "2026-09-28T13:38:02.682Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/a476197fcba557738a588ec844747a19bc0a24b0e6f1809e308f29d68c0e/duckdb-1.5.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ae352646374cacf48e9981cf031191c494865192fc436d13667a2531fc5d1da3", size = 32810376, upload-time = "2026-09-28T13:38:05.148Z" },
{ url = "https://files.pythonhosted.org/packages/0c/6d/5466a2b53ddd557644dfa47a763f68748efccdf282e6ae7c4f1bcfb3da69/duckdb-1.5.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a1261e90785e9d29953293e44f60fa073bd1137098924e8de21a037a861b051", size = 17405385, upload-time = "2026-09-28T13:38:07.363Z" },
{ url = "https://files.pythonhosted.org/packages/d4/a0/bf87071170835ee4a34fe764fc11c1c6e7040a0e021b36c1b6f834a4c22f/duckdb-1.5.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97dd7a555b8f5298b76bc7d48a11cb2c64336e8de9bfde783cffb86ea9f54807", size = 15533132, upload-time = "2026-09-28T13:38:09.681Z" },
{ url = "https://files.pythonhosted.org/packages/31/e0/38095c8e140ecfbe847519ac07bcba94301b8fbb76b2870015e33e07f179/duckdb-1.5.6-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:364992ba1089a2b327391cfcb68fd0bd0ce9090cf293baef861a0ba6847abfee", size = 19454994, upload-time = "2026-09-28T13:38:11.836Z" },
{ url = "https://files.pythonhosted.org/packages/70/21/61dd2876bbaa69cf77d7b5c620e52e8b25faae7096f4d2e4a812b52095d7/duckdb-1.5.6-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:644f54ce99b3b61844bc9a3fe80e0aecb1ea4084b1fffc4396d1569db6111679", size = 21568700, upload-time = "2026-09-28T13:38:14.258Z" },
{ url = "https://files.pythonhosted.org/packages/4a/4a/100730e7785e85268be4d4d5bd62cfc8314e261d2f42efa208243eef35cb/duckdb-1.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:ced693d33ddcee2e5345f077d342c87d2aaa80e41c514e64c9ff2d4e5963c251", size = 13190707, upload-time = "2026-09-28T13:38:16.875Z" },
{ url = "https://files.pythonhosted.org/packages/f3/2e/bc7f44eab4e89ee5c1cb427bb1168ad021d985042e6841ec0694c3d3d501/duckdb-1.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:41ecc75bb9328d72d154a705c1a653d2c5c60f686a5c0c6578aa80020753c884", size = 14020962, upload-time = "2026-09-28T13:38:19.007Z" },
{ url = "https://files.pythonhosted.org/packages/fb/62/a8a30a4c6b94c0861d348ed5633b963f6745a5525527530f02f3c1a7c931/duckdb-1.5.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:aa21d2ad803b2524326e8622d7d96b2bb1ff1d5b60368e1978ee805df9c21fb3", size = 32828003, upload-time = "2026-09-28T13:38:21.414Z" },
{ url = "https://files.pythonhosted.org/packages/71/b7/1dcca0005eb8c67adf9fc06bf0cbb1d2bf4ea1974cc89e7a7c2ad66aac28/duckdb-1.5.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8a1b2ad27d414068cbca06c55cfa802eece10f86ea4812ff082f8ab4cb25fc85", size = 17413912, upload-time = "2026-09-28T13:38:23.915Z" },
{ url = "https://files.pythonhosted.org/packages/93/b0/e3ac175443550f3464f2d95731a8b0aae9b4dc3875c3a186c352262b43c2/duckdb-1.5.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c79c6d222b1d015cde73b5139087186b00db65357fb4e2c94c2308fbbf465a72", size = 15543122, upload-time = "2026-09-28T13:38:26.317Z" },
{ url = "https://files.pythonhosted.org/packages/9d/08/cc510a7952aba69d5cdca17f3ef61c95713d86143f2ee9aa3e097d38f50b/duckdb-1.5.6-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1052b8050ef5696e2c0d8c836949c72f3dd11f0690466acbea739613e8e2750b", size = 19457946, upload-time = "2026-09-28T13:38:28.877Z" },
{ url = "https://files.pythonhosted.org/packages/ef/a5/6f8099d9a5a02ddff89e5c85875df3465054845b0920fb0703fbdf8dd2ec/duckdb-1.5.6-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:19c5e485e59613b8878d1670bcaa7a010f53c5a4da5ae8e08863e5e529ca6182", size = 21575132, upload-time = "2026-09-28T13:38:31.231Z" },
{ url = "https://files.pythonhosted.org/packages/9f/58/762f7159662d7859e201fa05ca29f306795daeabf84f3e087215a966b001/duckdb-1.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:ebcbd09cd8578ab1093393e9b16289cda0e8f1791ac595bf00eb5bad75c3cf00", size = 13713963, upload-time = "2026-09-28T13:38:33.543Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/64d165db322de13f5c3e75d377b6b9694df1821155ad1fa4b14b04601abc/duckdb-1.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:820a8384faef11cd86068ea48c5da57ce2d8f1c7b3d2bdb9be3398317a7c3728", size = 14514368, upload-time = "2026-09-28T13:38:35.676Z" },
]
[[package]]
name = "duckdb-backend"
version = "1.0.0"
source = { editable = "." }
dependencies = [
{ name = "duckdb" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "python-ulid" },
{ name = "pytz" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
]
[package.metadata]
requires-dist = [
{ name = "duckdb", specifier = ">=1.1.0" },
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24.0" },
{ name = "python-ulid", specifier = ">=3.0.0" },
{ name = "pytz", specifier = ">=2024.1" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
provides-extras = ["dev"]
[[package]]
name = "fastapi"
version = "0.143.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
]
[[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 = "httptools"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" },
{ url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" },
{ url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" },
{ url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" },
{ url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" },
{ url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" },
{ url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" },
{ url = "https://files.pythonhosted.org/packages/9c/04/223994f8589750d2a36ceb43203e739cf75bd9e12c226680d73567766908/httptools-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4fb995082fe41ec410b33c48b54fb1d44abb8a6ee762c31e8c42519e8c3a30a9", size = 117115, upload-time = "2026-10-09T19:54:53.356Z" },
{ url = "https://files.pythonhosted.org/packages/31/d8/b4407836e567a862ce79d78a628d785db99aba52e63496d68c60eed0d475/httptools-0.9.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:b9cd15cb7cf0d5cc41f649fd789aae12c56c3b83eff593f8e095c1d4555ad5c3", size = 113225, upload-time = "2026-10-09T19:54:54.81Z" },
{ url = "https://files.pythonhosted.org/packages/79/f6/0caa51b077492a7306bdbd9dfb907a2246985f0aed1fe2d086255921848b/httptools-0.9.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:088de1738e1af624466a01c35d652dbe6fb825be887c76d68aa850621d81db88", size = 520112, upload-time = "2026-10-09T19:54:56.3Z" },
{ url = "https://files.pythonhosted.org/packages/fa/da/7a47b7c2106bb10e6d4c04a139d045257a4f93c672fae6f0b9e92b1f7bc2/httptools-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b1ac7f1bc6c0dbf90684b77571a51a21b2463909fd916ce0ac9bfc4d566dc75", size = 516079, upload-time = "2026-10-09T19:54:57.938Z" },
{ url = "https://files.pythonhosted.org/packages/0f/4d/417b42d2663acf4f5aeb2718dc894ec2be4e3dcfd8caa2d3bf9ee2dce511/httptools-0.9.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9430f65db521db7962ad951571d446171213686f96c998a54dc18ed574821e2", size = 535040, upload-time = "2026-10-09T19:54:59.769Z" },
{ url = "https://files.pythonhosted.org/packages/cb/de/8df4c09a33ddaf50f697719f20201cf93631ef4b50cec05e42acf179a7c1/httptools-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52fe0176682a25b15370f23f5b0f1366a84771df89144fb0cd979cb72a94b5ca", size = 462799, upload-time = "2026-10-09T19:55:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/e8/90/1bfe91e3fca29c541d85d7ba8ed92a406d4dd13608c281baf7ec75369fec/httptools-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:757e3f79cb865a7db94e0db5f4d0ed3284a69e39d53568f433982ea13c60cac1", size = 497596, upload-time = "2026-10-09T19:55:03.201Z" },
{ url = "https://files.pythonhosted.org/packages/b0/af/2bbd5af0dd7a0e0c3b63bfefafd87a07041eb13d7cd710fbf30708b70773/httptools-0.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6ff5f0ed70783dcb9562dbd20edca51c3d4d277f128223709e3da6b75986d1d4", size = 517110, upload-time = "2026-10-09T19:55:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/d4/7a/9f165817c3e27df9098f3d50a675417d8721253f1073434f48a3f9d9a6c2/httptools-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0f537e5e8152e8d9cae82804024790cb973061abd3b7ef8f66f46e2b5c7bb51", size = 459198, upload-time = "2026-10-09T19:55:06.985Z" },
{ url = "https://files.pythonhosted.org/packages/93/20/b93279e334946c359d39aaf405241c6fd60f9e60da709bc4156731a4413c/httptools-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a7f1df31829c258158be01bb04eb668c4fba7df1ddf2262131a972962e651b6", size = 502996, upload-time = "2026-10-09T19:55:08.733Z" },
{ url = "https://files.pythonhosted.org/packages/86/c9/ac3657943d40c5a9949b72565ee03151e480fb18c062c7c13c0c0276df6f/httptools-0.9.0-cp313-cp313-win32.whl", hash = "sha256:714bf348f468532d86bed670837e7d5ddff3834dd7f5d3c08066da400c86f088", size = 85878, upload-time = "2026-10-09T19:55:10.275Z" },
{ url = "https://files.pythonhosted.org/packages/74/69/d23079cd4bc16d11e49c3f51c2540c018736f26701a2a73183cae9255a1c/httptools-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:805b0f2618e5d4c3e28f45b731eb1a0539691ae4a2f97b4ce014de0bf96a1ff5", size = 91549, upload-time = "2026-10-09T19:55:11.701Z" },
{ url = "https://files.pythonhosted.org/packages/0b/ed/5ff678a774b721f054c095f04d84fc536e7369ea4f4c9af3813a518d95b6/httptools-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:bfdabac0c6d3d6a5be8c2a100a001c92c14a39bbafd5999545a675c493626e64", size = 88043, upload-time = "2026-10-09T19:55:13.046Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/0965023968452245ece67b161adbf7c5652f8d0697ac69312f9d21849411/httptools-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a4050a651e1f2faf05eb028ce9f2168abbcee9e24b209f5c1f2eb96d8c569e4", size = 118142, upload-time = "2026-10-09T19:55:14.491Z" },
{ url = "https://files.pythonhosted.org/packages/31/39/a6ec662d81059e505e953af709797038e83e489014df721e506f4fd0d3c5/httptools-0.9.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:130635fea6e611a6b2026120037965ddb88b3dafd11bb64e264b101a70a76630", size = 113943, upload-time = "2026-10-09T19:55:15.887Z" },
{ url = "https://files.pythonhosted.org/packages/72/04/4ecb7251a6c55bef61b157bb93fd44678943c35702a5966e4d5ebda2d450/httptools-0.9.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18d800aaa2d6bff7d889df810d1b19a5fde72b1f6c0ca96e8d9f28a692fe5460", size = 514397, upload-time = "2026-10-09T19:55:17.48Z" },
{ url = "https://files.pythonhosted.org/packages/31/5a/0c26c98ee06f0f39608de715e7ca868baec942171a77feace5a0ba548ca6/httptools-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e45def4d9ce7073e2226535572442d9d6efb4047c7a5fd8960807e877ce70a", size = 514383, upload-time = "2026-10-09T19:55:19.221Z" },
{ url = "https://files.pythonhosted.org/packages/d4/6c/0f85d4f1f579c49aea6e4946dd304e9f33a680382b5117970ab887885bc7/httptools-0.9.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f6da814aeecbc6cb8872d6d3e85ed16e8ab1653f9557cea8658725ce212348a", size = 534993, upload-time = "2026-10-09T19:55:20.992Z" },
{ url = "https://files.pythonhosted.org/packages/3b/32/97a836533b7bc9e269fc6d075c2d27669ca9786bf43f229158b9b4b15021/httptools-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e1e037bb57dbc549c6fe20370b763ea74bdb09413cdcf857e4f14d9e4e2fb13", size = 461494, upload-time = "2026-10-09T19:55:22.785Z" },
{ url = "https://files.pythonhosted.org/packages/67/cf/a2d5e8dc3bad9b0b966bb546170234b4614275346cccbc01f6cdb6fce3b3/httptools-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cd3e55223a77d6e08d5730ebacb4930ecca5d2ce7c57e7ba10833be7e52903f1", size = 495859, upload-time = "2026-10-09T19:55:24.9Z" },
{ url = "https://files.pythonhosted.org/packages/bd/d9/7472c4ca2aa1cfe6d0f9923380784b034cb77addc88589f2e5c92fd3b4df/httptools-0.9.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:beb2c8a34cc90fb4d862b7284eafdb322030d6a8b2ee5eb6a744f84205beedc3", size = 516303, upload-time = "2026-10-09T19:55:26.84Z" },
{ url = "https://files.pythonhosted.org/packages/c1/dd/f9be002ba859714cc306fe86204b7cb12bac091be66a7e23d7bb25d259bb/httptools-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0cc339a807c156d840b54f8bf050ba0fc265eb81692c24bca8535b52fbd797c6", size = 458188, upload-time = "2026-10-09T19:55:28.571Z" },
{ url = "https://files.pythonhosted.org/packages/89/7a/ed8bb5344071afd12c87e57e8839fa65abc3895b92a5d065be79ecacb919/httptools-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ee42112d785a913dd63ec0335435a3dddbea5040c151252db815b0095cf066", size = 498356, upload-time = "2026-10-09T19:55:30.301Z" },
{ url = "https://files.pythonhosted.org/packages/04/8d/3f1390c901d4a266ad9d5b988c47c4883e322e6f6cc021c592b9a050fb19/httptools-0.9.0-cp314-cp314-win32.whl", hash = "sha256:d1e329a1866981efe0201d05a374617f6c6cf14434a501d78ab22793d1ab1fa6", size = 88479, upload-time = "2026-10-09T19:55:32.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/05/7de70a4eea3b52d31a95fe64eb5775ccdead01e4913e4741b4424e9ef180/httptools-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:edd5aa045fa3cc57143db018dd32ce7962bd5b525d05230709015d7e570100aa", size = 94809, upload-time = "2026-10-09T19:55:33.423Z" },
{ url = "https://files.pythonhosted.org/packages/e8/79/7f6c354a8f8f74381fd473f365d2db3cd976ee8d1422b8dd7455dfc52b62/httptools-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:6ff0145b34610e57c9fae20df4e133c8d54266447387de6fcc0bdabfe4db4569", size = 91508, upload-time = "2026-10-09T19:55:34.764Z" },
{ url = "https://files.pythonhosted.org/packages/94/0c/f9e8148ca684b41b4b5d0ced0860530b9a9bcb7c38bf727d83dcbfea42d0/httptools-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:80eae881cfb69383303e9a4d7961a478025b89c24f38f2e69b30c516fa0d57f2", size = 123684, upload-time = "2026-10-09T19:55:36.445Z" },
{ url = "https://files.pythonhosted.org/packages/3d/54/3c1d910e8f0bc9ee0ba7867b687e3272c8ae4a7da2df2fbf1b2bce77f0f9/httptools-0.9.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:b2ab3aad55d75d0b8df8d8a1b5920baaec9b161112cd5e95984848b4d2cd3dfe", size = 118378, upload-time = "2026-10-09T19:55:37.851Z" },
{ url = "https://files.pythonhosted.org/packages/d4/ce/3b9694880da927ae69b5629b8847cfe73d14584be2aa974a92ed2675b7da/httptools-0.9.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db735a23ecb0f0450d2b24e0a05fb00a8a35c9db172919c4d3e023e7c7ee4c9b", size = 591295, upload-time = "2026-10-09T19:55:39.501Z" },
{ url = "https://files.pythonhosted.org/packages/3c/89/1ff2835b6adf5c08a477d3a199e72b71e7f26df55ceaaed7d7364d745a1d/httptools-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:995b52f7c260ac7023640221f27472303968753cb6fc6fce1ddfb0e9db59a398", size = 603709, upload-time = "2026-10-09T19:55:41.404Z" },
{ url = "https://files.pythonhosted.org/packages/24/40/4f59a0d9dca6d60002e7cb5dbf1441b558ced5a65b5b4131d57cbbd7c806/httptools-0.9.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3af4e45ff455fce5511fdf2653c1ce428ef09c56fe37a83eb4d924c2d474f31e", size = 607379, upload-time = "2026-10-09T19:55:43.119Z" },
{ url = "https://files.pythonhosted.org/packages/bf/19/381d444a3ba704cd5c67eb4617ae7a08e920a8239c688f23ba0de07a270b/httptools-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ce8e723b4637034b76f5382a30a6b725518c332273e8d62a6c7d46e90837c947", size = 530031, upload-time = "2026-10-09T19:55:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c5/c9ba7758bf266240f598934510af4a800edafd9c8eb1fcf15feac0427063/httptools-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:465bc1526debf53a3be92022a16ca0c38f891ea3b5c1587af4f52e44020f8a07", size = 575129, upload-time = "2026-10-09T19:55:46.536Z" },
{ url = "https://files.pythonhosted.org/packages/db/87/c17f3a53616a3849681f7c8e913ce966487b95038504bbb035c38f5f2fbe/httptools-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:8463b34ebde3f000627e9dbd8a545f995ad49fbf7ff9dd5abc0cd507da98a603", size = 582996, upload-time = "2026-10-09T19:55:48.545Z" },
{ url = "https://files.pythonhosted.org/packages/88/e3/cb33ba1348ddfa5853f96021f4c38674ac383b92c944492cf7638bd6bfd0/httptools-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f9489c1d87160c126f73b004742fe8654fa1ce37ed89e9e01330a1c10aaecde4", size = 526150, upload-time = "2026-10-09T19:55:50.261Z" },
{ url = "https://files.pythonhosted.org/packages/e9/00/af0e2f33ba5be60803a492ad377e798714d0c970e76015e313849b351ef7/httptools-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:06bfe7fad972a417269d8a5fc53b87e4eca970354abf5e9e24336fd06d64292e", size = 571731, upload-time = "2026-10-09T19:55:52.422Z" },
{ url = "https://files.pythonhosted.org/packages/b6/35/e67e9c9dd3da036ebfcbd273eec44bd39213f952d638858b09b9f3ecaf3f/httptools-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:c42424213c28804f8d0e20f5692106cfb57bf72e1dbc4092b8481fb2f9e4c707", size = 94622, upload-time = "2026-10-09T19:55:53.982Z" },
{ url = "https://files.pythonhosted.org/packages/c5/5c/af620c73de59b5f3d431ae778c7412d30bba7bf56ca8b4140107a8ac0e54/httptools-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bb1533541c729ad422f870a780d8b4af924f9817d45b5f580390418cda72eaa2", size = 101934, upload-time = "2026-10-09T19:55:55.417Z" },
{ url = "https://files.pythonhosted.org/packages/90/90/fc6019b5179d13007c6c3039346ea2696cf2e94369d6ca96e57f23b01989/httptools-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6f9549ca354a1d6d6167c458a1f1b12147726b968f02dd64b6a5801dba91ae0f", size = 97211, upload-time = "2026-10-09T19:55:56.878Z" },
{ url = "https://files.pythonhosted.org/packages/d2/77/e226b16a2f291f2a4ce25a24a3297e98749d80b8a713b8f3b11d8a82e904/httptools-0.9.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:d3906b5c549ff2ad2473cb711e1fc65d76715c2726a402108fbf55eab6c6b49d", size = 117817, upload-time = "2026-10-09T19:55:58.295Z" },
{ url = "https://files.pythonhosted.org/packages/ff/08/050ad8985ec34064e4401e6e5aeca7238685bc218eaff20025f7c04b0723/httptools-0.9.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:cb2bb3ac0af7fdab2311b895c9eb95442b45deb14cc949b9e65545e74aa0be69", size = 113669, upload-time = "2026-10-09T19:55:59.915Z" },
{ url = "https://files.pythonhosted.org/packages/52/0f/af812488a4963ce59d97b73a00c72bba49f5eebca1a13ab6f114372b5e82/httptools-0.9.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63d38e9a9a10a20fb57593742e63c6b1e78dd7f6ef5472de8e0b1e4cf4f3db26", size = 515633, upload-time = "2026-10-09T19:56:01.529Z" },
{ url = "https://files.pythonhosted.org/packages/50/6d/73c987b84e0d02fa6c4109c7ce6ea00518d0aa3005fb92b75553ffd5ddf8/httptools-0.9.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eae4e9c7a0785a1a715de0a74fb822ab40084c060f444f18f075d05e322aa7ef", size = 516049, upload-time = "2026-10-09T19:56:03.327Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f9/74cc01fba5a0ea05501eb39eddba4baa00c10e4d1caebdb78f23eaacafe5/httptools-0.9.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0adc974916efe1fbf89d0363a86dcb2c746727643e362ff398de1a4b50b6bc77", size = 534832, upload-time = "2026-10-09T19:56:05.068Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a2/a7bb90643c059e8136c2a5fdfb0d7e1a18b2c5c4f1a78f2de14b1303184d/httptools-0.9.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:050f84b7ec46a6efe0e5f521cf8729e3397c1cef4384f62ed8d5d68ca0045776", size = 466489, upload-time = "2026-10-09T19:56:06.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/19/bb3f18e05cbad9628e7f1254176c475e05ac79c72697ec7c144fc2cc877f/httptools-0.9.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9b4da5789d7cf576c7e81f0088c632f6ee3786d87d17f08e90e703c22ce15633", size = 497146, upload-time = "2026-10-09T19:56:08.641Z" },
{ url = "https://files.pythonhosted.org/packages/25/e6/90e2433d7a947bec66a5ad22e948626a26672ff62aa3ebf949899f687a3e/httptools-0.9.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:f78f7ae1c2e5aabf29583fc0d302d8081a663776f84578025662eb6f5d63a921", size = 516153, upload-time = "2026-10-09T19:56:10.415Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c7/86373edd9d800eb723b8b68d3fce0e31d3e3211f9d7b0eaf8c3deadfada0/httptools-0.9.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b2cc6991f16f6d666d48e4b57318104e7b29109e32e2f6b86e9d44c4e6a27f4e", size = 463141, upload-time = "2026-10-09T19:56:12.406Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/8dc41d9ebf78fa56f609f251ed8ac5a9f66513b0ce712040bd7ada7b19cc/httptools-0.9.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:dbc9fd1521e573045d71b6afab7398439c5cc259e8cb9d416fe62d485c4899c6", size = 499125, upload-time = "2026-10-09T19:56:14.109Z" },
{ url = "https://files.pythonhosted.org/packages/7a/41/38db94fda8b266dcde50722a4fcef825b189380a220e02c682518bc1b430/httptools-0.9.0-cp315-cp315-win32.whl", hash = "sha256:34266cec8c1d4e3e91fcca7efe38971d6bdda64a7944f2a46ab576da15173680", size = 88370, upload-time = "2026-10-09T19:56:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/4a/cd/347f12eb16e20972dcdacbca907f2c52d72a36542199a5bf3ca342c92098/httptools-0.9.0-cp315-cp315-win_amd64.whl", hash = "sha256:b5a3f5f70967a1aa2bc47fec42a1e19d2fb38c61700e3ee62b63a4af4f4fd001", size = 94617, upload-time = "2026-10-09T19:56:17.257Z" },
{ url = "https://files.pythonhosted.org/packages/f3/08/086ba2f53989d504a05f4669b03673a04fc72554bc37d4696c3c6132be75/httptools-0.9.0-cp315-cp315-win_arm64.whl", hash = "sha256:e0acbd474d0af4afacc6e66c4273f8a19e25f8af4379fc816388095ea6b01371", size = 91358, upload-time = "2026-10-09T19:56:18.641Z" },
{ url = "https://files.pythonhosted.org/packages/3e/3a/9ba59ec76d45bf8eb7ad3a18f2c6e9074fa4ce5cbbd3900fffb8d840f9e7/httptools-0.9.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:02bc5b3dcb6394b9d825fd62a7bfa0b2943063a3c89abc4492ad45e334a20eb5", size = 123063, upload-time = "2026-10-09T19:56:20.023Z" },
{ url = "https://files.pythonhosted.org/packages/18/2d/49eb389bda75a8ef0d04bf025dfb8412a3646637051c8a88bdeea700e343/httptools-0.9.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:fc1a4f9d18d32a6e0a0a0a382986a60a2126f5144dd08715be7adb8df18e8a46", size = 117328, upload-time = "2026-10-09T19:56:21.439Z" },
{ url = "https://files.pythonhosted.org/packages/a0/6b/2d6439378fd3d1f9c06272b35d61f4519e2d9bf9967611df069fa6c23044/httptools-0.9.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:df3867518b205be3648e2fbd522bf380c851b5c2500588047505afdd786b6669", size = 588680, upload-time = "2026-10-09T19:56:23.056Z" },
{ url = "https://files.pythonhosted.org/packages/08/65/3fb50e861bbb6103ca58fd88b4127d346fc909eb9f06d250455033a3f698/httptools-0.9.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26e1d9629f3bf70d23f0d22238152aec51c837a7c9e384cb74f356fdccad7eb3", size = 600031, upload-time = "2026-10-09T19:56:25.216Z" },
{ url = "https://files.pythonhosted.org/packages/90/9b/40d33d4098fde007845804b1c923ddf5a27fd48aca1c8080bdbdac6c16fa/httptools-0.9.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:050f7ab098121873c8f13e35857f97ab60a76185c8302bde9a384939bb7c3b96", size = 604407, upload-time = "2026-10-09T19:56:27.04Z" },
{ url = "https://files.pythonhosted.org/packages/17/37/472afc9000aca3c7dd61a9b8ac6f3e2765900e3614f8d7f13e772c9c5438/httptools-0.9.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d90d10e9b6594c28f27896a68fab97fd784c43804e9fe419dab8e8dcfcf4b02", size = 529459, upload-time = "2026-10-09T19:56:28.944Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/9956910fb1d181578249cd2cc966c0c46ad3c558b43ac2b79af50f94589f/httptools-0.9.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b928ab0ecaa664e8caecc529dcb8bc881b6b35bb2b74bf9a39ae25f982ee8812", size = 570845, upload-time = "2026-10-09T19:56:30.602Z" },
{ url = "https://files.pythonhosted.org/packages/30/8c/d1c160a3cc2c18e41a6f763c3aad979530dfb295039449312b8814e19753/httptools-0.9.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2319858018eedd0c0b2f950a620413c0a9d1352607be4267eb28209eca8b1e3f", size = 579194, upload-time = "2026-10-09T19:56:32.353Z" },
{ url = "https://files.pythonhosted.org/packages/90/3c/3f7cc49925928a8c82f4141d504b8b8c2901c4b35cb88800211828312561/httptools-0.9.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:931f45f84e15daafec5f82cc92e6710569e1f50933f3253d206eab4132bec678", size = 524950, upload-time = "2026-10-09T19:56:34.103Z" },
{ url = "https://files.pythonhosted.org/packages/19/98/8e2154e99b8e8818fad3e6c5dd7cf21c050f6314b1bd8072e8dc29f49eb5/httptools-0.9.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f67db0ba2bedafec15b8e5330d40da1e1c7921559fa715af021252bfef81a6f8", size = 568603, upload-time = "2026-10-09T19:56:35.876Z" },
{ url = "https://files.pythonhosted.org/packages/79/a3/86fe9fef3a1bfab5db62262f8880c294cbf8a8d94cffe2a2aa8b4aeed40c/httptools-0.9.0-cp315-cp315t-win32.whl", hash = "sha256:2095207b75a83c9e947346da9c127fb7e4fb29f41589df2643764f06b750989c", size = 94042, upload-time = "2026-10-09T19:56:37.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/4d/f2d88782251467325a62ec4ad704249bb1b09c21aacb997181a9f4421f30/httptools-0.9.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bca180cbe84e4fba7807eb408a8655295f697928512324517e30a091ede522a8", size = 100837, upload-time = "2026-10-09T19:56:38.831Z" },
{ url = "https://files.pythonhosted.org/packages/00/4b/5e96c4e0d171f959a0064971c3fced9cea5a19e5fab7a8e7d57aceb80506/httptools-0.9.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a4d8c2c7e73ba5967be74d7c3a5ff81fde815ee1b48d9c5c0f14de8463a847b", size = 95947, upload-time = "2026-10-09T19:56:40.562Z" },
]
[[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.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[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.14.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/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" },
{ url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" },
{ url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" },
{ url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" },
{ url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" },
{ url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" },
{ url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" },
{ url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" },
{ url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" },
{ url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" },
{ url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" },
{ url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" },
{ url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" },
{ url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" },
{ url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" },
{ url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" },
{ url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" },
{ url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" },
{ url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" },
{ url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" },
{ url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" },
{ url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" },
{ url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" },
{ url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" },
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" },
{ url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" },
{ url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" },
{ url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" },
{ url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
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/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "python-ulid"
version = "4.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/41/65079023c81491a21799c0120bce5925366b6913596bf797806f19973290/python_ulid-4.0.1.tar.gz", hash = "sha256:bbeec02556190bb9dc3401faa7268696acbfbe7b6db9908c155dc3548629f20c", size = 101683, upload-time = "2026-07-20T15:21:41.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b4/15/8b39b36f55b6618ec4ca9b55134dcfd9c04cecbc72709f5d8e6bdebed9cd/python_ulid-4.0.1-py3-none-any.whl", hash = "sha256:6f1d69ceb97e99fe542df8476ebcd7a668284bf53ee14b3106bcc6a341a95ed9", size = 14609, upload-time = "2026-07-20T15:21:40.214Z" },
]
[[package]]
name = "pytz"
version = "2026.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/21/d83d6ef28c4c912c4bb4d1dcf591f7b8c6bde87b9c66f9f454677314e16d/pytz-2026.5.tar.gz", hash = "sha256:fa23724b9c486543b9ff54a327ee7569ac83ade54bb9afd0fc18676620401c86", size = 318572, upload-time = "2026-10-04T02:37:58.719Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/ef/c66110d46fb800dda0bf33164182dfadabe26a90e4476844d502a23dca8e/pytz-2026.5-py2.py3-none-any.whl", hash = "sha256:e658af3757f9e26a9d25dd2aff38335acd92bc9104f890a894b2c1ba28311b03", size = 506342, upload-time = "2026-10-04T02:37:56.814Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "starlette"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/6efb252d091ecccd7d62048ae11f0ea35cd75a4fbaeea5e30f9c3bf91d10/starlette-1.8.0.tar.gz", hash = "sha256:1565dc0b35d5737a271ed1e0e04e949f4e81198799f216d2667b0a0fb9cf9522", size = 2730457, upload-time = "2026-10-13T07:54:39.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/b0/5742e4ac7af5eb58ec3470a537a49d7aa507e5539413e504b3a65ef50ba8/starlette-1.8.0-py3-none-any.whl", hash = "sha256:dfdd6b29c26483288088d990eee59631dedadd66ce20d203402a7ca8e3c4656f", size = 79612, upload-time = "2026-10-13T07:54:38.019Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
{ url = "https://files.pythonhosted.org/packages/5f/83/eb980d64e6dd5da46d4dc35755fa6afd6b5b47141437cf89615f1117c5a6/uvloop-0.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2dcff2d69be43e6559e5dad2c5a7a2dbfb60e05a77311b6c4b7a4a8123d86c65", size = 1412726, upload-time = "2026-10-01T03:15:52.49Z" },
{ url = "https://files.pythonhosted.org/packages/04/c1/02a725e7698134c647904bdee6589e2be14a0e7fc9942c74f86e2b90d48b/uvloop-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19c64108b507cd0bc140e400e3396bacebd9d504956aa7726272bf6de7d9aabb", size = 779071, upload-time = "2026-10-01T03:15:54.02Z" },
{ url = "https://files.pythonhosted.org/packages/0b/1d/cde53c79e8c01884ad1cdca8e407e086d523362cfe4139e2c2a8dde27304/uvloop-0.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1748321e3c59a14a75404b1ae8d5a8d81c4e201803ea0e14c1b6fd84421024b5", size = 4395323, upload-time = "2026-10-01T03:15:55.549Z" },
{ url = "https://files.pythonhosted.org/packages/98/54/b12915bebbf99d7ae0796211e7f5977b95f069830dca45dc1a346d84125d/uvloop-0.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2cba180d6451822763eda8364f342435a873bcfb3849cbd82fdeca248ca65eb", size = 4480449, upload-time = "2026-10-01T03:15:57.362Z" },
{ url = "https://files.pythonhosted.org/packages/f7/8e/da6de68c31549a052a105fc76f5a9a204f6df22cb0909440aa4dbb06f9a2/uvloop-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc61e4f9e37b507069dc7e659ae28bca7adcb04c993c3508214315d12c63f848", size = 4219177, upload-time = "2026-10-01T03:15:59.351Z" },
{ url = "https://files.pythonhosted.org/packages/a1/c3/1b53c6a89dc9c9d5cb75eb9a0b891ad69b32e1421ad3aa01617a9cbdcc78/uvloop-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7337b06a9f9ed9ea3049f04b76f65819db9b19bb832ee598e97b388eadf25e5f", size = 4346132, upload-time = "2026-10-01T03:16:01.064Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a4/00e85345871c59c834a23c136c1771205856028ecc8ba940b3951178e59b/uvloop-0.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b90397a50ad6332ed3e459c648ac20d182cce24a557354363ad85fc9ea4a17cd", size = 1421363, upload-time = "2026-10-01T03:16:02.599Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a9/e5f0f3cfde30af3ec32eba8ec07bccdba2b5116afbd1ecc53edfeb0a0790/uvloop-0.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:be53e1d5f83de43dc175c87612ecc128d444b38e5c56cb3f807f5a73d6887476", size = 785177, upload-time = "2026-10-01T03:16:04.018Z" },
{ url = "https://files.pythonhosted.org/packages/9e/79/9ddf78f8cd75a15c14a09a57f59c587b8cd9d82802c5c8368b9c3ebefa0b/uvloop-0.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b3cbc4f96ddfa1fb88a78a69dd851369825b7816d9702eee8c4461505ba172e", size = 4381060, upload-time = "2026-10-01T03:16:05.642Z" },
{ url = "https://files.pythonhosted.org/packages/1e/20/57d63c44d32326878fcad5c63854afc9deb394ed95673c1b1a429178c79d/uvloop-0.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e0cf90bc8fd88784f6802cdba968a51fb1aec1cc3feec74d862b2d371d1330", size = 4418891, upload-time = "2026-10-01T03:16:07.326Z" },
{ url = "https://files.pythonhosted.org/packages/12/c5/0795abecda2cc3dfe41033f880a32a9ff103be4e6b177ac736833c153a0e/uvloop-0.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa8ed556fcc87a4091cf61587ef172fa104323dc89ecc085a618ba7ff8629a8f", size = 4214811, upload-time = "2026-10-01T03:16:09.13Z" },
{ url = "https://files.pythonhosted.org/packages/20/18/9010dacd5221eec1bd79a4a83ac68f3db6a42d7bb657f7b640c4838ca6b6/uvloop-0.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3fbfe82829d8e381426a289b87e59e585278728361db9ce975b88b51f64f410", size = 4294876, upload-time = "2026-10-01T03:16:10.875Z" },
{ url = "https://files.pythonhosted.org/packages/b1/08/f6384a03c771d00067cba4f542a69b2fc1a982e9fd78b357c2f788678d72/uvloop-0.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:7e35c9bc977760981693e1a7a51493b58ee5a501f9ebb1e547565ee40b6c6208", size = 1494811, upload-time = "2026-10-01T03:16:12.399Z" },
{ url = "https://files.pythonhosted.org/packages/ac/01/756a4fb24a449f313cf4a153eb0c6210b49cfe5539255ec9fb1e17d2c4ef/uvloop-0.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5bb9be71d9ee39b4359b832f9569518ec9bc08704194034e79e4958e6bc4d46d", size = 819396, upload-time = "2026-10-01T03:16:14.094Z" },
{ url = "https://files.pythonhosted.org/packages/3e/45/e314b0c600b14f53dad3a3c2d7a922a249a88225fd727652b53e1854b9dd/uvloop-0.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e84575f11873c109cf3962ad0bdf679094466184125f4cadcc41a73febff41f", size = 4734966, upload-time = "2026-10-01T03:16:15.815Z" },
{ url = "https://files.pythonhosted.org/packages/66/0d/8686a7f0b1b2d55ebd770ba21f8e0e4ffa0cde5ab738f43ffb8264499052/uvloop-0.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbbdb8fcd5e7062e546eec1ac78c28bb21ae7df54c18f8e4b06e15a18d661a49", size = 4584963, upload-time = "2026-10-01T03:16:18.198Z" },
{ url = "https://files.pythonhosted.org/packages/78/b2/034a2d47e435ac02357c42956246887167bdc0357bdd6ad31c5f6d94497b/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:76345f51367fb1f23e08605c6efb18374f669be5b223658fbab6b17627950507", size = 4421388, upload-time = "2026-10-01T03:16:19.953Z" },
{ url = "https://files.pythonhosted.org/packages/f0/77/131f4b583e6b4b715c404a66b51c812d701db20f25c9018b188a2b00062c/uvloop-0.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c7ef4701a96553514b2688e342ef1bf2beae6cfd172d89a76c768292aabf405", size = 4402414, upload-time = "2026-10-01T03:16:21.716Z" },
{ url = "https://files.pythonhosted.org/packages/58/3d/ee11f4718ea1280595c67ed25c83d4c92115dc100bbdfd192d3ed9339168/uvloop-0.23.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f1341c6abcee1c31277cfe28d34e46196f2143ec3d755e6efe7452126e1f626d", size = 1418095, upload-time = "2026-10-01T03:16:23.241Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0c/7ca516a0671418517d79a09d3ff2ccbb44af94c75711afa6e4cf58aa6f65/uvloop-0.23.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e095f9e105af76593b4c183bb0bcbdae64bd913a59ec595732dc108b48730ab5", size = 784837, upload-time = "2026-10-01T03:16:24.666Z" },
{ url = "https://files.pythonhosted.org/packages/35/95/75d4e28e596d505b7ae11de517646b4ca3d369fb8537ba755410380da11a/uvloop-0.23.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f673d835bdb1a60229cc3609a113fd2c9ce3f4a3c75ad4eaed111180c00199d2", size = 4380276, upload-time = "2026-10-01T03:16:26.389Z" },
{ url = "https://files.pythonhosted.org/packages/10/99/68daf827ad62efaf4667d1f3fda127046d42161178396bdd93aab3684082/uvloop-0.23.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3f23f403a273900d57de6ee5ca0614c650f7f58563065dad1a4744498960e53", size = 4451496, upload-time = "2026-10-01T03:16:28.364Z" },
{ url = "https://files.pythonhosted.org/packages/71/69/f67e696ee688f426a96f99099bae26fec14a1d0fa75dccdd6518ee267c0c/uvloop-0.23.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:cbe8d03d4efcccdb7fcedecbaa1e1fa02913eaf3a74cb933634a6bc6d2ea9e2a", size = 4212541, upload-time = "2026-10-01T03:16:30.014Z" },
{ url = "https://files.pythonhosted.org/packages/f1/6a/c8c436a9d7453297b4be70bdf6a9f9fc9400da45e0059ddf7b28ab63f4c7/uvloop-0.23.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:4f1798f56c6f4ba5ac11fa2869e5717926e4470d97a1dd42b4f59219d43b5027", size = 4319377, upload-time = "2026-10-01T03:16:31.705Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2c/8fc15a03489299aab8a6212dfe0f137dc39836f915c87f7fd9d9ddd814de/uvloop-0.23.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:098a85e1393ef5202767b7e5fb41a32cd8bd81e6ee4af364c179801c4aa3f6d4", size = 1493428, upload-time = "2026-10-01T03:16:33.859Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7c/05e4a210790229607f71460fcb2ed4a2c7bc72668d8a928ce577c22e38f8/uvloop-0.23.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:5a2bbad3a63007f7e9524d4903ba04fee252557c2acd86f9a3d4f91786695254", size = 818115, upload-time = "2026-10-01T03:16:35.45Z" },
{ url = "https://files.pythonhosted.org/packages/65/14/a40b11c6c024213803b13955664a15754c72f64c873a33d986b26ec9ff5b/uvloop-0.23.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a08875543bbd4519faf30497506c9cda8a48470467ffdf967c7313c7a5981a8", size = 4734149, upload-time = "2026-10-01T03:16:37.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/83/f421a077712c1e87603bfec62744c3cd3a2f4b47378025db3d740df9af0d/uvloop-0.23.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12634f15e6625f78b3f2922f91404c4d7173487eba11746764153f556e9852dc", size = 4661763, upload-time = "2026-10-01T03:16:38.719Z" },
{ url = "https://files.pythonhosted.org/packages/f5/62/25dcaa6b7e7b48f82ce633854ce96597ab768f9650931f4f86c572de392c/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:378188efbb1524f2219d05246a3e1e5907217848d2882144dff59585f1b81d55", size = 4421324, upload-time = "2026-10-01T03:16:40.488Z" },
{ url = "https://files.pythonhosted.org/packages/05/46/04628239b43dcef703af314202a3307d6060918e2d76aa86c5b1188f5551/uvloop-0.23.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4b8e207c67d207a8608fec57e116511030af3495dc0109b8c333cf9cb412b16f", size = 4462501, upload-time = "2026-10-01T03:16:42.359Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" },
{ url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" },
{ url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" },
{ url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" },
{ url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" },
{ url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" },
{ url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" },
{ url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" },
{ url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" },
{ url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" },
{ url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
{ url = "https://files.pythonhosted.org/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
{ url = "https://files.pythonhosted.org/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
{ url = "https://files.pythonhosted.org/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
{ url = "https://files.pythonhosted.org/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
{ url = "https://files.pythonhosted.org/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
{ url = "https://files.pythonhosted.org/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
{ url = "https://files.pythonhosted.org/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
{ url = "https://files.pythonhosted.org/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
{ url = "https://files.pythonhosted.org/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
{ url = "https://files.pythonhosted.org/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
{ url = "https://files.pythonhosted.org/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
{ url = "https://files.pythonhosted.org/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
{ url = "https://files.pythonhosted.org/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
{ url = "https://files.pythonhosted.org/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
{ url = "https://files.pythonhosted.org/packages/54/54/a935a32dbc2e7365b1b59eb74b5ab7515456f02370fdca4c4efc3574e96f/websockets-17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b24b83fbb34b2d8de06cf0f0d4bd7737344ef854482a614826d4356c0c3f0c12", size = 217752, upload-time = "2026-10-03T14:53:54.59Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/cb8881851abe2662730e6c61cc521b4c96513fdf9103a44f169afce2eba8/websockets-17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8a829db795e3f87053904493d184b185c8eb1f497c852f434168ec856aa6f997", size = 215436, upload-time = "2026-10-03T14:53:56.034Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1e/621bb93f35ab7d337be98f1958294437527e2a1797089b5e734ddc5eec5f/websockets-17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cf8811d285acc91216368df7fb55cc8c9bf6fcd90eea42429c7186c7385a12b9", size = 215690, upload-time = "2026-10-03T14:53:57.587Z" },
{ url = "https://files.pythonhosted.org/packages/62/4a/49d0c983c082676d5d413b28e6ba5ae1d174c00268467bf78d9fe986a2d2/websockets-17.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:89c4898da776193577279173dcf9860487590611d7320d379435a145881b048d", size = 225080, upload-time = "2026-10-03T14:53:59.081Z" },
{ url = "https://files.pythonhosted.org/packages/04/13/95a45eb410019772002d8f53d81396dad4120f7df39ca9962f86f5d7cd01/websockets-17.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d87091c4347daadbcc0833b65812ff38d7350c67339625d4e4a512cf38e3e8ef", size = 225361, upload-time = "2026-10-03T14:54:00.61Z" },
{ url = "https://files.pythonhosted.org/packages/f8/fe/0f0eda80bb441f54becdaf793eb20ee080926f8d2356388377cf262187e5/websockets-17.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1110fbfd530c447380e6e6db88b7e43ffe33d54178f5b0ff0aaa5a280301e668", size = 226602, upload-time = "2026-10-03T14:54:02.098Z" },
{ url = "https://files.pythonhosted.org/packages/5c/36/067fc09d8e6f154abde7c2f747c52cc442a02c5eb14816f5c39cb9f8bcc6/websockets-17.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:83abd8beab056aa77a116364811f8fc262dffbcc7abea48de0c85ccbfc6f1428", size = 228035, upload-time = "2026-10-03T14:54:03.545Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a2/939bade7a396b4c381aebbf3941969f124d0f98d56753f81cd256f3fc4d6/websockets-17.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:876da8ca5520d65b5d0f2ca6b4e7a00d35bb90ccda35cb2ce3cda4b6c711e84a", size = 227227, upload-time = "2026-10-03T14:54:05.045Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8a/37b1033e21709dd7fa39239ea4d9cd7f348ad5bcba94eb47253878576f8a/websockets-17.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8462395df8f224d2daa3d80db3ae4450d9d4b7243c8483ac79a82862f1599dd6", size = 225985, upload-time = "2026-10-03T14:54:06.81Z" },
{ url = "https://files.pythonhosted.org/packages/a0/3a/0d89539900b06d86366facb7558198046de125ab8c371d9248d6262da70d/websockets-17.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6e9a04e69456015e6ae5e0d486d995137fd435794442122b00ce5f9526ea3ba8", size = 223226, upload-time = "2026-10-03T14:54:08.583Z" },
{ url = "https://files.pythonhosted.org/packages/31/9a/bfc5633e3d538d0a71cfbe7a5fee56c712e16c2dbd0ce17c83196a2a96a9/websockets-17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8a2321bcb73758c44c8076509024d02c15ee484fe77ce04edea4bf4d257492cc", size = 226042, upload-time = "2026-10-03T14:54:10.254Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/cbaf1786d8e3aeafe9d76951fc01139ec353b92555580336f23669382a55/websockets-17.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8be4a87b3baca380ec3c7b1643b2dd268ac9d42c5097c0e8dc9a49342faf4774", size = 224639, upload-time = "2026-10-03T14:54:11.911Z" },
{ url = "https://files.pythonhosted.org/packages/80/49/175faa5bd169486f835602ac0ae6303318aa65693b79cdc72c5ee53b148d/websockets-17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:eb7b737ce8d18c8a08beb68f751572b7bf6a18093ecd1406ca1256b50592552e", size = 225407, upload-time = "2026-10-03T14:54:13.489Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d1/3662f612456cfb2dcc128c8e596f0a55fb7b695025e2ebe8ba2abb355c3b/websockets-17.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d6605630c2808b33f362d6d08582e79821f77ed2bd3f49f9d467ea70defea06d", size = 226513, upload-time = "2026-10-03T14:54:15.046Z" },
{ url = "https://files.pythonhosted.org/packages/73/6b/07af5177a49e30156b0922556fa93624a920a2b17d3e63bf4ad94668112c/websockets-17.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dd9252828073fd0d69e7667af4275a1b17c18d0833b1ab7f59db272f194a6b9a", size = 224072, upload-time = "2026-10-03T14:54:16.574Z" },
{ url = "https://files.pythonhosted.org/packages/eb/34/d18054ff4d8314524164f8b8efec2cb17627287e099f122c28ed6fa598e0/websockets-17.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:06c7386128a9d85de4e1960114604f3031c084d2f4eee8db382637f1634cbab1", size = 225022, upload-time = "2026-10-03T14:54:18.143Z" },
{ url = "https://files.pythonhosted.org/packages/e9/12/75433caa3e9fa3e51d7751dc6bad24a86addf76cbfb51e52b11d037ba7fd/websockets-17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98f2d03df74977fd252831c997c388cd6c3f691a8a9d022b266d3cbd9849838f", size = 225303, upload-time = "2026-10-03T14:54:19.679Z" },
{ url = "https://files.pythonhosted.org/packages/6f/de/23e21c002aa2786ac9807c0876faa3b2576493b29ca3386287b0db46f021/websockets-17.2-cp313-cp313-win32.whl", hash = "sha256:5b43a1f7e4853ce08c3f6d3bf69799ee5b46548bfb71792a8158f7e45d66b547", size = 218219, upload-time = "2026-10-03T14:54:21.232Z" },
{ url = "https://files.pythonhosted.org/packages/13/eb/960411c0c574535d629c16e96a2b4e5353dbe4109df8ecea859e1b5245ee/websockets-17.2-cp313-cp313-win_amd64.whl", hash = "sha256:27c7a59b5352a8f741b422820adfe89dfe47c8f2d84fb32111e76111edaa0e83", size = 218531, upload-time = "2026-10-03T14:54:23.025Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1a/3ac07bb52378952eff1d52d04a7ee6e82ce84e3da319a52a4739cd9c78f5/websockets-17.2-cp313-cp313-win_arm64.whl", hash = "sha256:533b7c82bb1eafbeb921dfe131c9f88e55451ddc328d84bde1c9340ba72d2808", size = 218466, upload-time = "2026-10-03T14:54:24.857Z" },
{ url = "https://files.pythonhosted.org/packages/8b/74/6bc991a28ac983600e65de408ebd1b1413d554ed0468ae5c831bc52dded6/websockets-17.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:ecb748910e9ba4624ebe2057791df51dcbffb48c37108ab94a3c593472023c9e", size = 217791, upload-time = "2026-10-03T14:54:26.381Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2f/158e99426be6e71d09520bae53f29294fbb614b2fc5fbf8867b1d08395a7/websockets-17.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ab9af5cb7265899e659f079eb71691375a1025b6d5fbd3caa495dd08f70833a", size = 215486, upload-time = "2026-10-03T14:54:27.962Z" },
{ url = "https://files.pythonhosted.org/packages/5c/09/1abf942723c0001d9c2fca1551907dade6304517b982b0bf10bba107fa81/websockets-17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:06e46da092bca3a52e98f0458c66b247993ce501a07cd09c858be3296511ab7d", size = 215699, upload-time = "2026-10-03T14:54:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/a7/1d/1ade03963ef497c47e6bad79e24370827b2fe6145fa8f58070ff2b7dcbac/websockets-17.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fcce735ffd72ac4056db05325d9f0232382b74826f0196eb6a15ca903abdaa0f", size = 225081, upload-time = "2026-10-03T14:54:31.278Z" },
{ url = "https://files.pythonhosted.org/packages/9f/fd/47b8a0361c49da939b976a07b27a72a9f893d01dfcf4d2a28b53419ce1ef/websockets-17.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:42cbca10f82a8b2fb1536e8a0830ca6ceeb6bb3d8d64b766e0795369135654a8", size = 225430, upload-time = "2026-10-03T14:54:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/f0/26/f4d4c76264ee037c5556ab5f50fcba302746dabf7528955534e4dda9965e/websockets-17.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63ff5a21f26bd0e6a8464b53fadbe174825c8718ac14180df45665eaacdb6af", size = 226676, upload-time = "2026-10-03T14:54:34.833Z" },
{ url = "https://files.pythonhosted.org/packages/37/b3/c8b1c981322a050c4babfd327ffc9880f9c3834f5b15d2574e37eeb8768c/websockets-17.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63f543463601c1558b755f8dd7618b6ec3dd0934dda051d3b7030d8c76e54de2", size = 228048, upload-time = "2026-10-03T14:54:36.424Z" },
{ url = "https://files.pythonhosted.org/packages/f0/5a/1cb29ddb23e6bc27ffd1c5316cd3616360d1ba0c3854eaa134ee3207bd28/websockets-17.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c32eb565ad9ce8a6444248e5b7a19dbb86a81c811fe5fcc2fba7a735aed5163", size = 227281, upload-time = "2026-10-03T14:54:38.01Z" },
{ url = "https://files.pythonhosted.org/packages/ba/64/135274572dc0c845fc1111e2b932c807c395daac75d6eae6cfa148d8a208/websockets-17.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d459bbb6c22f26dcebea56924a362aba50d453b9867912862c970434fcf0d94", size = 226025, upload-time = "2026-10-03T14:54:39.613Z" },
{ url = "https://files.pythonhosted.org/packages/58/75/f1e386aec3124489411caf5138cdd5a2bc43d3fd4a681c69adcf5f6272a5/websockets-17.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f19ca1a21871f024e38faf4107b433047df27558dff1b72a1dac31481e2c1fe5", size = 223277, upload-time = "2026-10-03T14:54:41.165Z" },
{ url = "https://files.pythonhosted.org/packages/60/eb/24733a0f568c2eb99e60f9faa620a98fb228c06a01e7e2f348b33290ed9c/websockets-17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c76b4bcbf0f713194591673fc86a42820e14da6bbd1bb445d3d002cc4d1e4521", size = 226148, upload-time = "2026-10-03T14:54:42.779Z" },
{ url = "https://files.pythonhosted.org/packages/55/6d/ea66a30af74f5983cae31ebb9ef78b178b366a12856a414e1472225c4a34/websockets-17.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:30201a7f69833b015556c72feb69ea501b645986fd0b90dab13f589e995ff428", size = 224615, upload-time = "2026-10-03T14:54:44.41Z" },
{ url = "https://files.pythonhosted.org/packages/87/80/c6f2228ad89774429d270179375ebddb657119215f52d1df7c680d65cad7/websockets-17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0c8600aec354cc259f1691b0b42816f04a9886a953f82cb227246df76057f97a", size = 225398, upload-time = "2026-10-03T14:54:46.063Z" },
{ url = "https://files.pythonhosted.org/packages/f7/4a/3d8da19732ad468d4be7f1e3ac298078b60bdda55edde6589bef84a5eb7e/websockets-17.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:307fc22ea496be8542d67b82ae8c867a978dfd19ac35573d4f15943fd9277dfe", size = 226571, upload-time = "2026-10-03T14:54:47.672Z" },
{ url = "https://files.pythonhosted.org/packages/58/22/1231657122d9cc24791bb90af13cc2f4e84cf0d3a454cb37e3abfdcb2fd9/websockets-17.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9c88697fa943bd4ef67cc919a17d81de6581846f52bfa8c6f64a916098986556", size = 224125, upload-time = "2026-10-03T14:54:49.537Z" },
{ url = "https://files.pythonhosted.org/packages/1a/04/350ca2445da758bc42cdb4218b44d4ce0d5a9c1d5e4cc4a58d64348ad9da/websockets-17.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f7eac84d4969da82166d5e90d9c38d2f416fe24f9708a7013569b193745b9a31", size = 225081, upload-time = "2026-10-03T14:54:51.075Z" },
{ url = "https://files.pythonhosted.org/packages/da/c4/dec952b0df3a5d918ed2a545abb0c25ae519c3bc2d9aba3b7c46abae8f05/websockets-17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:313f6703023d53baabab6d6c5c37cf637b2c4fee255acf2ed5e92ad69e28f1b7", size = 225376, upload-time = "2026-10-03T14:54:52.675Z" },
{ url = "https://files.pythonhosted.org/packages/f2/b4/198a260afbcc086ff4979774e51834ed7fb5b95f9ef305e0c4924630b857/websockets-17.2-cp314-cp314-win32.whl", hash = "sha256:08d90cf344bdb971ba3a826b78d4da9bfd56cc6a97a604d9b88cbd40bfa6c735", size = 217760, upload-time = "2026-10-03T14:54:54.247Z" },
{ url = "https://files.pythonhosted.org/packages/e5/9e/0523f8bc2f7aaddf39562d4fa01b4d38fa61b23d980917a16d2dd19c8dac/websockets-17.2-cp314-cp314-win_amd64.whl", hash = "sha256:dac93bf7a9beb215be3282b8441173cd50806c41c007b8be9bb24e03c60ad563", size = 218104, upload-time = "2026-10-03T14:54:55.845Z" },
{ url = "https://files.pythonhosted.org/packages/55/17/7b8bb4cb64a199e7082f1f9be784d657842fefc327ac777d6c1493504804/websockets-17.2-cp314-cp314-win_arm64.whl", hash = "sha256:2ab742249f953d148a9ba696c8b9944361e8cb92e8bc61ba2dd53a178403afd3", size = 217989, upload-time = "2026-10-03T14:54:57.376Z" },
{ url = "https://files.pythonhosted.org/packages/ee/76/f54ed054b6e860f1e0bbc7019542a048352d41231fdff6d904b379f881c7/websockets-17.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a69ce25be5f1330ee1c74eb6fabbbceaa96b384beedd2627cecded7546490c40", size = 218125, upload-time = "2026-10-03T14:54:58.943Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4c/0f3375cea66a125ae01d21fb9c537aae955ef499bfe7e2b2376a34362f2a/websockets-17.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8e24b878cf54843a63985d90480f163ca7f692689fbcbe9cdbd8165521083a8b", size = 215658, upload-time = "2026-10-03T14:55:00.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/05/7c871a67bfb4b61adc1fe13583db97803f87dfeca644fe6ef51df7bb276d/websockets-17.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f33c7908a6885dcae9f462a4a8347b637053b4ff2b96beb4c23fba1cf7818e5f", size = 215858, upload-time = "2026-10-03T14:55:02.379Z" },
{ url = "https://files.pythonhosted.org/packages/41/8e/59df4d9cd357e902d1c74b13c3c0c3841c8df6e4b1b3d131bf26a23fdcb1/websockets-17.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c796a1bb3e4015249639849f30e8e680df8a431b45d417ba8acf843d2451d95f", size = 225443, upload-time = "2026-10-03T14:55:03.966Z" },
{ url = "https://files.pythonhosted.org/packages/5c/64/5e486a3a44e041203c62eccf1fc89c7f8824e21104a7b82b182e5b21c228/websockets-17.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:983bcdc898662f6ba9d6a025c30d29946ff0986d9ad60d400af0da3671f7cbf3", size = 225726, upload-time = "2026-10-03T14:55:05.797Z" },
{ url = "https://files.pythonhosted.org/packages/f0/98/b6eb53121c91fbe8b6897aba06861ce60f9ab58faffc6bca5750cbc21681/websockets-17.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35e0f088ddfd9d9bc5019e27ff3767411779e92b59db5bb1507f2731a5b61158", size = 226895, upload-time = "2026-10-03T14:55:07.626Z" },
{ url = "https://files.pythonhosted.org/packages/8a/18/8c091321b99c91eb3eaec9acbd940e69308b4e465b5605c430af0cf7d3a5/websockets-17.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:19e2511412ad3393191de652513bc7a0ca3c93af143b32d96d46e59fbbddf1d4", size = 229040, upload-time = "2026-10-03T14:55:09.321Z" },
{ url = "https://files.pythonhosted.org/packages/1a/96/3a92f944305b7de42fcb7530b9fa69607b4b4ce993c36a9f2330dbc318ba/websockets-17.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb5e2bf969ac99a6ae3c71208a5eb05cfde973192540ffa6e1068b57fb78c4f8", size = 227469, upload-time = "2026-10-03T14:55:10.935Z" },
{ url = "https://files.pythonhosted.org/packages/ea/a9/624f6d75ba326c22d03698b34c0ada984f1d76196322a62f6c22903b831d/websockets-17.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:691780fca2be3dec512cb603cb91060271968cb4af86b51d07c57445c5754a37", size = 226202, upload-time = "2026-10-03T14:55:12.536Z" },
{ url = "https://files.pythonhosted.org/packages/47/af/1e6e8c625aeb268830af2c4227fe05e8db59f4f4debe1dadfd0ada214895/websockets-17.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2d39c19b1ba6a6791050383fd69efdd3b63533e2254693d0263879cd5f5921ba", size = 223743, upload-time = "2026-10-03T14:55:14.164Z" },
{ url = "https://files.pythonhosted.org/packages/dd/81/33c5280f4f6f81637c93ae065c6a594dfe35935622af135a5f7c3768bf22/websockets-17.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e48ac2b302986c6f55cf61e8e36b4dd97d0132c5078a713a697a940934ba422e", size = 226492, upload-time = "2026-10-03T14:55:15.796Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/7aa9fc36e67caccbcfee2c48f4ada41e9da512d41523c024d039f0f22ba3/websockets-17.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e136197f1262620ef2e507afc3ea759c1ae7d221886da20eec5f4c9f2618c2aa", size = 224940, upload-time = "2026-10-03T14:55:17.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/8c/457aff7081a63d1261608bb4d7b0b0f9dfe780697a2a334671745742850b/websockets-17.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3eb44019a2b0b3b91bac95998f1e4e5589730421170e060fe654a2b7be727dc7", size = 225835, upload-time = "2026-10-03T14:55:19.607Z" },
{ url = "https://files.pythonhosted.org/packages/3e/c3/7a13a3b3050db2c36772ded49f8d48f99eb080948e9f6f762e7529925ab5/websockets-17.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e5855e574804398859c5fbaf4fc7882b96278b7f6572a3d889627e6eb6cfca59", size = 226848, upload-time = "2026-10-03T14:55:21.274Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3e/d5b2c1e473b1031a4a0ec0e10de69df5b981ab4a10aa482bb45c18dd43f5/websockets-17.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5dc29815520c329f5662f6eb3ebadecf0d4f8c82dfa416d4d6efbf8f39245559", size = 224541, upload-time = "2026-10-03T14:55:22.874Z" },
{ url = "https://files.pythonhosted.org/packages/79/5d/bb81976cc1aa546afb51395ce42913521e9dea062bb34a61308cfff30726/websockets-17.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d1a4f9462da6496b6cb79bbb09c60d17f7e63e8a1df136797b3afabec9560e4d", size = 225315, upload-time = "2026-10-03T14:55:24.443Z" },
{ url = "https://files.pythonhosted.org/packages/f4/6b/314962d5440c61b4c107914599c13ceeecc6bdb6e2e73a5f7e566a7d1f26/websockets-17.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9496bff5541086478264678bac73c0a75b2fde94fdf6568893bca1f7c6d50d18", size = 225747, upload-time = "2026-10-03T14:55:26.033Z" },
{ url = "https://files.pythonhosted.org/packages/98/fc/9eb64b34a3a4458eb08f3f24bde01508f72a00790330723c158ebb965048/websockets-17.2-cp314-cp314t-win32.whl", hash = "sha256:e1e3bc8090a7eae79fdf634b63bdbfa3c93999991023c37c6fd3b469fc8ff5dc", size = 217891, upload-time = "2026-10-03T14:55:27.681Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ed/3a4e2a09b0822d6e525cbc6e44a4885669bad5b22ab9c64fa2444bc15325/websockets-17.2-cp314-cp314t-win_amd64.whl", hash = "sha256:65a89a5bde227bfe908016f35b5bd347970cd1e5b0360f389502eba1c7fde6e0", size = 218229, upload-time = "2026-10-03T14:55:29.314Z" },
{ url = "https://files.pythonhosted.org/packages/b5/66/cffb75ee746dd060984c3c3e2eac7f875a866225a30dfa53e2cd18232565/websockets-17.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1c27339934109dfaca83f18ab2c23db06714e9d5deca2c8e37e8f492ab90d20b", size = 218146, upload-time = "2026-10-03T14:55:31.001Z" },
{ url = "https://files.pythonhosted.org/packages/12/e9/10a9b1633b63594054c87b97af048628cea2b21b5089a52a9fc1e0af60a3/websockets-17.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a7c4bb26de6ef496d24822aee4f6a305d97cd33d21a2b85f290292d69ba1c25e", size = 217719, upload-time = "2026-10-03T14:55:32.674Z" },
{ url = "https://files.pythonhosted.org/packages/0c/00/ff4020fe0886dac7199a16ce2805c7afd7b981bd2e81d3fa18dff5d9863a/websockets-17.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:c08da1f15040bd1e1a6074bd4518a6ef20e67b1594ecfb0aa75e5b45f87e6d6d", size = 215448, upload-time = "2026-10-03T14:55:34.338Z" },
{ url = "https://files.pythonhosted.org/packages/66/06/bc7b944f81514378b2c2ab96c17df19e871cd33b9be0f1f6dfc975457e5e/websockets-17.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3117abfd32b183bdb6194df9317766d32c6517f3d1c0aa8c62d5c6ccfda0b4a8", size = 215674, upload-time = "2026-10-03T14:55:35.918Z" },
{ url = "https://files.pythonhosted.org/packages/a8/da/2b2b76faa2f10c4813e3872c9577fd13a798f5918b1785b86ff7d635eb2a/websockets-17.2-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a046227daa7f191e843d26b911c1146233e9a33d249e0c954dcb3ac7c398710e", size = 225119, upload-time = "2026-10-03T14:55:37.777Z" },
{ url = "https://files.pythonhosted.org/packages/ae/d4/22cbe288c0d5cef7620503be92c0098d82220353fc7e188034a19c517240/websockets-17.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2901bdf24f20bc884124b3e88c61f7ece260c20c81e610f2196007395264a4aa", size = 225549, upload-time = "2026-10-03T14:55:39.364Z" },
{ url = "https://files.pythonhosted.org/packages/4c/0a/504b0d3063679f2c60430c3539482d42a4cb8bd1a76646baf742030a93cc/websockets-17.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f60e39adfecf998488166aca8ff24ab1ac406c9ecbecbcf9b3bcfc43cb1ec9a1", size = 226717, upload-time = "2026-10-03T14:55:40.942Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ea/5da9309cc55c2665a6eebc22c369d9918c0d77258c61e92058e6b08d5ff1/websockets-17.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d4df62fd8448a85c752bbea1803cb3a2785e6fc8352009ab64ad7447af079b3c", size = 228413, upload-time = "2026-10-03T14:55:42.54Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/5a24df72aa5500f311105687af864c27f1f9da910e968e97818c6149e6b0/websockets-17.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8eea55fdfa9ba65c6981eea38bd20c800bce2f092a2803d82de764ecf0f071a", size = 227196, upload-time = "2026-10-03T14:55:44.251Z" },
{ url = "https://files.pythonhosted.org/packages/5e/ee/ca32cc1ed892dc4ac30a922e8f648048233fbdb8b0bce7048860ec4c60ec/websockets-17.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3f0def1279644acaa9bc861d4234af3f82ea9cee7e460dffac5cb63e691501e9", size = 226092, upload-time = "2026-10-03T14:55:45.842Z" },
{ url = "https://files.pythonhosted.org/packages/7d/0c/12d4a73324aa9798d5165d20c088f9dba66c75c871960e5d921ec66694e4/websockets-17.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb78fb4158c12f77a934a003006784108a27a6553cfc0c6f10483c9c02e94f48", size = 223486, upload-time = "2026-10-03T14:55:47.45Z" },
{ url = "https://files.pythonhosted.org/packages/bc/a4/7fe15da5abb8f0f61e6a357593f7f2ed55724825b7db0ffe72b5c5fad68d/websockets-17.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f8969ad228115ad8869b5fed801f899e52ab8ad376fdb165ba4760a277c8258a", size = 226200, upload-time = "2026-10-03T14:55:49.126Z" },
{ url = "https://files.pythonhosted.org/packages/08/b9/4cd3a311f96a2eea0ed458bc01fe2cce42f9cd50aa9e64315dfc855d63a9/websockets-17.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:4a49ca342efc0800e6ae94ed5c9cbdcb319308f75e73c21181e4c24d6710e8dd", size = 224862, upload-time = "2026-10-03T14:55:50.674Z" },
{ url = "https://files.pythonhosted.org/packages/41/b5/22caa3460f75e42bfcc74028870b556d22847ea9a9034aa03986f07f16a9/websockets-17.2-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:06fa3ce9c3154826c33d4395b225b2994aa64f1f3bcd8be8ed932019175d9268", size = 225391, upload-time = "2026-10-03T14:55:52.393Z" },
{ url = "https://files.pythonhosted.org/packages/95/be/8d28f92092076abf1ddfb3206b0ce956120a22e7c3105f6a3029d727deae/websockets-17.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:50644d8715be7e0ec0682f9d7744b63008e199c5e1618a48fa153756a332235f", size = 226545, upload-time = "2026-10-03T14:55:54.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/7b/ff943fa383e540fe17f066cc10a3eeedef26e50fd45aae2bdc6746d6f95a/websockets-17.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:60deca33e584c09e91f70f8b55a0b1de7d671d6a63f051d154920f48bed717c7", size = 224352, upload-time = "2026-10-03T14:55:55.856Z" },
{ url = "https://files.pythonhosted.org/packages/e9/df/1e6c3e06c473c9fd833a5c1620b15e2c3b37647b91b7d41871d20bc098de/websockets-17.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b5f79366a8d8dbb981d53ba800bb54a95454595ab8a4548c2b95501b32a08326", size = 225255, upload-time = "2026-10-03T14:55:57.497Z" },
{ url = "https://files.pythonhosted.org/packages/db/f8/d8a4f988f7cbb568d8bd69da4632c5b6010aa9cd9366f285e23b73b678d9/websockets-17.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f2bbf3f28d0b63157577c8b774b9136f076afa6797e1a52a2ecd477f23cad3a8", size = 225513, upload-time = "2026-10-03T14:55:59.338Z" },
{ url = "https://files.pythonhosted.org/packages/75/e0/920357165b2797a2530fc9e271d79a9b5fee2b750b154c990c740f767af3/websockets-17.2-cp315-cp315-win32.whl", hash = "sha256:74836317b7010b579522bb52426f1e225608b042c9e78cbe2493522bebb8a318", size = 217722, upload-time = "2026-10-03T14:56:01.307Z" },
{ url = "https://files.pythonhosted.org/packages/5f/eb/25bdca25bbc329ffb330ef33993397d6556a871e40a0d196e757699ea3f7/websockets-17.2-cp315-cp315-win_amd64.whl", hash = "sha256:aaead3d926e9ab4124ada727d20cd62d396649917822df4f771d1f07f1079b40", size = 218017, upload-time = "2026-10-03T14:56:02.914Z" },
{ url = "https://files.pythonhosted.org/packages/fa/cb/ea30a552bbcd1c75f0d14bfce6c884ee36187030b85b74a242aacc02406e/websockets-17.2-cp315-cp315-win_arm64.whl", hash = "sha256:40960554e60eb60c3eec4ff9e42a80f84f8cd3ca9bc80a5481a61f1e64d807c9", size = 217929, upload-time = "2026-10-03T14:56:04.604Z" },
{ url = "https://files.pythonhosted.org/packages/4a/01/477664c619af8aa3c908d482e2a95e13ceed9d78f21d15902013c3bc6c28/websockets-17.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9a2a60a7f0ea5f239efb6391d2b28630a640d82dad63e3bee47cf2c623c4495d", size = 218029, upload-time = "2026-10-03T14:56:06.336Z" },
{ url = "https://files.pythonhosted.org/packages/2a/a9/b0be62ff1c0e2bc966da56b36d3d820c7e2ad3c0c4a4ac414fc7335b214f/websockets-17.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:cca2fcb72c007103740fa4fc3df19fdb1a318c641c69f3b0cc47ed63a889336e", size = 215607, upload-time = "2026-10-03T14:56:08.035Z" },
{ url = "https://files.pythonhosted.org/packages/fc/2b/a6738530de0437a31c1b168e4096ecf790aafaf561f33a009886c7d8042e/websockets-17.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b789356bc4e2e6c20ba52817f92c3fed74e24657654237ecd536c54843b80c6c", size = 215817, upload-time = "2026-10-03T14:56:09.852Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c2/2fc44ddc419cbb09ee1708af3e78d8a4b018db01fc7e4f91bd730e2f8d9e/websockets-17.2-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:222fb626fa15701a850eccc778be17312142b2f6a0e16aea80770b7459adb784", size = 225979, upload-time = "2026-10-03T14:56:11.85Z" },
{ url = "https://files.pythonhosted.org/packages/2e/91/a215b14caa7ea65bc36db81609108899c259503300d1560dae9c70a135e7/websockets-17.2-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4497e87c34a2d21cbec1227858fec3af8e514dd70c47625557a122fcebc081dc", size = 226250, upload-time = "2026-10-03T14:56:13.548Z" },
{ url = "https://files.pythonhosted.org/packages/65/b9/9406a18e9edf558ed504d2a7679371d0f8107e4ef526c80b154ea4ec9752/websockets-17.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6281c171557ce0e408e19d9a223f22d915117ac38a5a7f32ed83809e7492316c", size = 227579, upload-time = "2026-10-03T14:56:15.143Z" },
{ url = "https://files.pythonhosted.org/packages/fe/45/a73af119244f46f5130005d7ab63f1c75890c890141a0ca2adc9d97d4671/websockets-17.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:08d97098644728bd1895caa7ecf3090b8e563d70809870d2adb33a107bd061d0", size = 229205, upload-time = "2026-10-03T14:56:17.086Z" },
{ url = "https://files.pythonhosted.org/packages/c1/92/ccd8e2e921d134a56f1ed4642d276500d9e33b3dc4d6deb63d614b3e53a6/websockets-17.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1fdb8d5a1660307dc6d36d0b7fc725213cbd7f80800904dc4896aa3208b89121", size = 228011, upload-time = "2026-10-03T14:56:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/e0/ef/7d71105d19a7aaab5ff87b9c712f6c1dda44e72ea56aa0e7b777f2fc274b/websockets-17.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18b0a46e5e9b315e2b54ce8c3bafdeef0e1388ca363114fa868e6aab2dc58512", size = 226892, upload-time = "2026-10-03T14:56:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/56/f7/87012d628b21e66e699440f39bfa7cc55fae7f52b2c532ab62184a589624/websockets-17.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f115d5d804a2163dd89245710049078b0e726a58c1f44a1f86c2c6e79055d76", size = 224241, upload-time = "2026-10-03T14:56:22.257Z" },
{ url = "https://files.pythonhosted.org/packages/55/f5/495371068b27ee5f7c435187f9dafd62402f195e2c76063bdd4653da1565/websockets-17.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:1d829946a2e7630f92f9d7b45b62f3abe9f393cc2dea6a35edb3988f865e75f2", size = 227076, upload-time = "2026-10-03T14:56:23.909Z" },
{ url = "https://files.pythonhosted.org/packages/18/18/3dce3cc6099be5e044e0fd5d0e0c9931c8e3387511cdec8014a345f619e5/websockets-17.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:6c274fc1572edf7c197094a0eb1887d45fdc95254bc80597dc7599550486c06a", size = 225727, upload-time = "2026-10-03T14:56:25.689Z" },
{ url = "https://files.pythonhosted.org/packages/47/30/57d0c7aaf8d4473926fa8829b8136483f561388d1e747ae71c9f2a83d5fd/websockets-17.2-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4173a4b8a025ae44313d9d9b4ecf31e886c7b7faf45386d51a8ca4ff2dcf3f2a", size = 226225, upload-time = "2026-10-03T14:56:27.246Z" },
{ url = "https://files.pythonhosted.org/packages/0c/9f/9dce1203756756c00b407b9a6b13a7500fcd38f2634d4daa3f65575814ec/websockets-17.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:d8cfe9522ad69b6abb26b413ed1deca43cb915cefc588433d557cb3ae1c783e2", size = 227333, upload-time = "2026-10-03T14:56:28.811Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2f/d3b6b876678ebb03017b7afd7111fe44d54b93f036a80ebb4b481dd1ab74/websockets-17.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:908d81d88bb16141613a6275059b5114656d5c2f0b5400b421d54fe6f1943507", size = 225082, upload-time = "2026-10-03T14:56:30.578Z" },
{ url = "https://files.pythonhosted.org/packages/32/b0/a69b573a5e56d2e7a5dcbb447466f442380cf81515e1cb1220cd626c8042/websockets-17.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:c6590e1eb624ff6b15b872421bc9a10bc6d2057635d69c6cd244ac3f928f85c6", size = 225945, upload-time = "2026-10-03T14:56:32.32Z" },
{ url = "https://files.pythonhosted.org/packages/70/be/a72911dc8e33f74c196012366ce4d99b1a803894a377a1ed0c8e66df9caa/websockets-17.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:61040f6f7da5a279d2f77496c69d51132aba75f701c52bded400d4c639277b18", size = 226241, upload-time = "2026-10-03T14:56:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a9/02a68c1d8e5572918e0962d3aad881078f73ede43abd9b1336e4efaa8909/websockets-17.2-cp315-cp315t-win32.whl", hash = "sha256:f90bad2839c185a1edf8ee22a257cfc8a39e0e337a0490ab185dfa76ef04d1bd", size = 217847, upload-time = "2026-10-03T14:56:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/3d7c33b8d5e7712a60e0149c017ed50394ec5e8cf72e5cb6a1ffaf11a42d/websockets-17.2-cp315-cp315t-win_amd64.whl", hash = "sha256:315551f4ccedbbf9fd4f7e8bf037a5948c976ade0e919ba5d8f581d465f6f725", size = 218169, upload-time = "2026-10-03T14:56:37.79Z" },
{ url = "https://files.pythonhosted.org/packages/27/57/ab34cc6460c5322e6932750fa5c6c64be89e6ee4e2707d13c4e9d3312b25/websockets-17.2-cp315-cp315t-win_arm64.whl", hash = "sha256:0a6220bdf8d5f11af71251a599092d89ac1d6bfac691c7f5951c5b07953947a0", size = 218089, upload-time = "2026-10-03T14:56:39.427Z" },
{ url = "https://files.pythonhosted.org/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md — Mila Tracking App
## Development
```bash
cd projects/mila
npm install
npm run dev
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN mkdir -p /usr/share/nginx/html/tracking && ln -s /usr/share/nginx/html /usr/share/nginx/html/tracking/mila
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
# Mila Tracking App
Mobile-first PWA dashboard for baby sleep, feeding, diaper changes, and WHO growth milestones.
Built with Vue 3 (Composition API), Vite, TypeScript, and Tailwind CSS.
## Features
- **Live State & Timers**: Active sleep duration or wake window updated in real time.
- **Sleep & Routine Tracking**: Today's naps and night sleep with running indicators.
- **Nutritional Logging**: Bottles (ml, Pre/Muttermilch) and solids (Brei g, kcal).
- **Diapers**: Instant 1-tap logging for wet and dirty changes.
- **WHO Growth Standards**: Automatic LMS percentile and z-score calculations (Girls 0-24 Months).
- **Git Sync Awareness**: Real-time warning banner if git background sync reports merge conflicts.
- **Backend**: Backed by FastAPI DuckDB backend at `/duckdb-api/v1`.
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de" class="dark overflow-x-hidden w-full max-w-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Mila — Schlaf & Entwicklung</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Mila" />
<meta name="application-name" content="Mila" />
<meta name="theme-color" content="#090A0F" />
<meta name="msapplication-TileColor" content="#090A0F" />
<!-- Manifest with credentials for forward-auth SSO -->
<link rel="manifest" href="/tracking/mila/mila.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/tracking/mila/mila-apple-touch-icon-v3.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/tracking/mila/mila-icon-512-v3.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/tracking/mila/mila-icon-192-v3.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/tracking/mila/mila-icon-32-v3.png" />
<link rel="icon" type="image/svg+xml" href="/tracking/mila/mila-icon.svg" />
<link rel="shortcut icon" href="/tracking/mila/favicon.ico" />
<!-- Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&family=Syne:wght@700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet" />
</head>
<body class="bg-[#090A0F] text-zinc-100 min-h-screen font-sans antialiased selection:bg-purple-500 selection:text-white pb-20 overflow-x-hidden w-full max-w-full">
<div id="app" class="w-full max-w-full overflow-x-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
server {
listen 80;
server_name _;
absolute_redirect off;
port_in_redirect off;
root /usr/share/nginx/html;
index index.html;
include /etc/nginx/mime.types;
types {
application/manifest+json webmanifest;
}
location = / {
return 301 /tracking/mila/;
}
location = /tracking/mila {
return 301 /tracking/mila/;
}
location /tracking/mila/ {
try_files $uri $uri/ /tracking/mila/index.html /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
# Service worker
location ~* /sw\.js$ {
default_type application/javascript;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Service-Worker-Allowed "/tracking/mila/";
}
# Webmanifest and manifest.json
location ~* /(?:mila\.webmanifest|manifest\.webmanifest|manifest\.json)$ {
default_type application/manifest+json;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Static assets cache
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+2251
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "mila-tracking",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 219 KiB

File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/mila/",
"name": "Mila — Schlaf & Entwicklung",
"short_name": "Mila",
"description": "Schlaf-, Trink- und Windeltagebuch für Mila Graf",
"start_url": "/tracking/mila/",
"scope": "/tracking/mila/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#090A0F",
"theme_color": "#090A0F",
"icons": [
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-192-v3.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/mila/mila-icon-512-v3.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/mila/mila-apple-touch-icon-v3.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/mila/mila-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+51
View File
@@ -0,0 +1,51 @@
// Minimal service worker for Mila PWA with instant cache-bypassing navigation
const CACHE_NAME = 'mila-pwa-v5';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
});
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
import type { MilaDashboardData, DayDetailData } from './types'
const API_BASE = '/duckdb-api/v1'
export function getLocalDateString(): string {
const d = new Date()
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export async function fetchMilaDashboard(asOf?: string): Promise<MilaDashboardData> {
const url = asOf ? `${API_BASE}/mila/dashboard?as_of=${encodeURIComponent(asOf)}` : `${API_BASE}/mila/dashboard`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to fetch dashboard: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function fetchMilaDayDetail(date: string): Promise<DayDetailData> {
const res = await fetch(`${API_BASE}/mila/day/${encodeURIComponent(date)}`)
if (!res.ok) {
throw new Error(`Failed to fetch day detail for ${date}: ${res.statusText}`)
}
return res.json()
}
export async function logEvent(payload: {
kind: string
subject: string
payload: Record<string, any>
start?: string
end?: string
date?: string
since?: string
note?: string
actor?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to log event')
}
return res.json()
}
export async function stopSleepEvent(payload: {
subject: string
end_time?: string
since?: string
event_id?: string
}): Promise<any> {
const res = await fetch(`${API_BASE}/events/stop`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'sleep.session',
...payload,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to stop sleep event')
}
return res.json()
}
export async function deleteEvent(eventId: string): Promise<any> {
const res = await fetch(`${API_BASE}/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to delete event')
}
return res.json()
}
@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { HistoryRow, MilaDashboardData } from '../types'
import { Utensils, Milk, Apple, Flame, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
dashboard: MilaDashboardData
}>()
// Latest 14 days reversed (chronological left-to-right)
const days = computed(() => {
return [...props.history.slice(0, 14)].reverse()
})
// Today's summary
const todayFeed = computed(() => {
return props.dashboard.today.feeding_summary
})
// 7-day averages
const avgBottleMl = computed(() => props.dashboard.averages.avg_7_feeding_ml || 0)
const avgSolidG = computed(() => props.dashboard.averages.avg_7_solid_g || 0)
// Calculated kcal estimate:
// Pre-milk is ~67 kcal / 100 ml (0.67 kcal/ml)
// Brei is roughly ~70 kcal / 100 g (0.70 kcal/g)
const todayEstimatedKcal = computed(() => {
if (todayFeed.value.total_kcal > 0) return todayFeed.value.total_kcal
const milkKcal = (todayFeed.value.total_consumed_ml || 0) * 0.67
const solidKcal = (todayFeed.value.total_solid_g || 0) * 0.70
return Math.round(milkKcal + solidKcal)
})
// Target kcal (approx 650 kcal for 7.2kg infant at ~85 kcal/kg according to EFSA)
const targetKcal = 650
const kcalPercent = computed(() => {
return Math.min(100, Math.round((todayEstimatedKcal.value / targetKcal) * 100))
})
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-amber-500/15 border border-amber-500/30 flex items-center justify-center text-amber-300">
<Utensils class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Ernährung & Beikost-Wissenschaft</h3>
<p class="text-xs text-zinc-400">Milch-zu-Brei Transition nach DGKJ & FKE Dortmund</p>
</div>
</div>
<!-- Quick Status Badges -->
<div class="flex items-center gap-2 text-xs font-mono">
<span class="px-2.5 py-1 rounded-xl bg-amber-500/15 border border-amber-500/30 text-amber-300 flex items-center gap-1.5">
<Milk class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_consumed_ml }} ml</span>
</span>
<span class="px-2.5 py-1 rounded-xl bg-orange-500/15 border border-orange-500/30 text-orange-300 flex items-center gap-1.5">
<Apple class="w-3.5 h-3.5" />
<span>{{ todayFeed.total_solid_g }} g</span>
</span>
</div>
</div>
<!-- Energy & Transition Hero Card -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<!-- Card 1: Energy & Calorie Goal -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="text-zinc-400 font-semibold uppercase font-mono flex items-center gap-1">
<Flame class="w-3.5 h-3.5 text-amber-400" />
<span>Tages-Energie</span>
</span>
<span class="font-bold text-amber-300 font-mono">~{{ todayEstimatedKcal }} / {{ targetKcal }} kcal</span>
</div>
<div class="w-full h-2.5 bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
<div
:style="{ width: `${kcalPercent}%` }"
class="h-full bg-gradient-to-r from-amber-500 to-orange-500 rounded-full transition-all duration-500"
></div>
</div>
<p class="text-[10px] text-zinc-500 leading-tight">
EFSA-Empfehlung: ca. 80–85 kcal/kg Körpergewicht (~620–680 kcal/Tag bei ~7.4 kg).
</p>
</div>
<!-- Card 2: 7-Tage Milch-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Milch-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-amber-300">{{ avgBottleMl }} ml</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Sichert die Grundversorgung mit Flüssigkeit, Eiweiß und Kalzium im Beikost-Alter.
</p>
</div>
<!-- Card 3: 7-Tage Brei-Ø -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-1">
<span class="text-[11px] text-zinc-400 font-semibold uppercase font-mono block">Brei-Schnitt (7T)</span>
<div class="text-2xl font-extrabold font-mono text-orange-300">{{ avgSolidG }} g</div>
<p class="text-[10px] text-zinc-500 leading-tight">
Fördert Kau- und Schluckmotorik sowie Nährstoffdichte (Eisen, Zink).
</p>
</div>
</div>
<!-- 14-Day Transition Micro-Chart -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs pb-1">
<span class="font-semibold text-zinc-300">14-Tage Verlauf: Milch (ml) vs. Brei (g)</span>
<div class="flex items-center gap-3 text-[11px]">
<span class="flex items-center gap-1 text-amber-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-amber-500"></span> Milch (ml / 10)
</span>
<span class="flex items-center gap-1 text-orange-300 font-mono">
<span class="w-2.5 h-2.5 rounded bg-orange-500"></span> Brei (g)
</span>
</div>
</div>
<div class="flex items-end justify-between gap-1.5 h-24 pt-2 border-t border-zinc-800/60">
<div
v-for="d in days"
:key="d.date"
class="flex-1 flex flex-col items-center justify-end h-full gap-1 group relative cursor-pointer"
>
<!-- Tooltip hover -->
<div class="hidden group-hover:block absolute bottom-full mb-1 z-20 bg-zinc-900 border border-zinc-700 text-zinc-200 text-[10px] p-1.5 rounded-lg whitespace-nowrap shadow-xl font-mono">
{{ d.weekday }} {{ d.date.slice(5) }}: {{ d.feed_bottles_ml }}ml / {{ d.feed_solid_g }}g
</div>
<div class="w-full flex items-end justify-center gap-0.5 h-16">
<!-- Milk Bar (scaled to 1000ml max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_bottles_ml / 1000) * 100)}%` }"
class="w-1.5 sm:w-2 bg-amber-500/80 group-hover:bg-amber-400 rounded-t-sm transition-all"
></div>
<!-- Solid Bar (scaled to 300g max = 100%) -->
<div
:style="{ height: `${Math.min(100, (d.feed_solid_g / 300) * 100)}%` }"
class="w-1.5 sm:w-2 bg-orange-500/80 group-hover:bg-orange-400 rounded-t-sm transition-all"
></div>
</div>
<span class="text-[8px] font-mono text-zinc-500 group-hover:text-zinc-300">
{{ d.date.slice(8) }}
</span>
</div>
</div>
</div>
<!-- Pediatric Guidance Stufenplan (FKE Dortmund) -->
<div class="p-4 rounded-2xl bg-zinc-950/40 border border-zinc-800/60 space-y-2">
<div class="flex items-center gap-1.5 text-xs font-bold text-zinc-300 uppercase tracking-wider font-mono">
<Sparkles class="w-3.5 h-3.5 text-purple-400" />
<span>FKE Dortmund: Ernährungsplan im 1. Lebensjahr</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2.5 text-xs pt-1">
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-emerald-400 block font-mono">① Mittag: Gemüse-Kartoffel-Fleisch</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Liefert wichtiges Eisen und Zink zur Blutbildung und Hirnentwicklung nach Aufbrauchen der pränatalen Speicher.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-sky-400 block font-mono">② Abend: Milch-Getreide-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Kombiniert Kalzium aus Vollmilch mit komplexen Kohlenhydraten (Hafer/Grieß) für langanhaltende Sättigung in der Nacht.
</p>
</div>
<div class="p-2.5 rounded-xl bg-zinc-900/60 border border-zinc-800/80 space-y-1">
<span class="font-bold text-amber-400 block font-mono">③ Nachmittag: Getreide-Obst-Brei</span>
<p class="text-[11px] text-zinc-400 leading-tight">
Milchfreier Brei; Vitamin C aus Früchten optimiert die pflanzliche Eisenresorption und liefert milde Ballaststoffe.
</p>
</div>
</div>
</div>
</div>
</template>
+589
View File
@@ -0,0 +1,589 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { HistoryRow } from '../types'
import { Moon, BarChart2, Calendar, Award } from 'lucide-vue-next'
const props = defineProps<{
history: HistoryRow[]
}>()
const activePeriod = ref<'7d' | '14d' | '30d'>('7d')
const activeView = ref<'trend' | 'pattern' | 'norms'>('trend')
const activeIndex = ref<number | null>(null)
// Slice history rows for selected period (history is newest first, so reverse for chart chronological left-to-right)
const periodDays = computed(() => {
const count = activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30
const slice = props.history.slice(0, count)
return [...slice].reverse()
})
// Max hours for chart Y-axis scale (normally 18h is good ceiling for infant 24h sleep)
const yMaxHours = 18
// Chart dimensions
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 32
const paddingRight = 16
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
// Coordinates helper
function getY(hours: number): number {
const clamped = Math.max(0, Math.min(yMaxHours, hours))
return paddingTop + innerHeight - (clamped / yMaxHours) * innerHeight
}
// WHO Recommended sleep band (12h to 15h)
const whoYTop = computed(() => getY(15))
const whoYBottom = computed(() => getY(12))
const whoBandHeight = computed(() => whoYBottom.value - whoYTop.value)
// Day bar calculations with 7-day moving average
const bars = computed(() => {
const days = periodDays.value
if (!days.length) return []
const slotWidth = innerWidth / days.length
const barWidth = Math.max(8, Math.min(28, slotWidth * 0.65))
return days.map((day, idx) => {
const xCenter = paddingLeft + (idx + 0.5) * slotWidth
const x = xCenter - barWidth / 2
let nightHours = 0
let napHours = 0
if (day.sessions && day.sessions.length > 0) {
for (const s of day.sessions) {
const mins = s.duration_minutes || 0
if (s.type === 'night') {
nightHours += mins / 60
} else {
napHours += mins / 60
}
}
} else {
nightHours = Math.min(day.total_hours, Math.max(0, day.total_hours * 0.75))
napHours = Math.max(0, day.total_hours - nightHours)
}
const totalHours = Math.max(0, day.total_hours)
const yTotal = getY(totalHours)
const yNight = getY(nightHours)
const nightHeight = Math.max(0, (paddingTop + innerHeight) - yNight)
const napHeight = Math.max(0, yNight - yTotal)
// Moving average of previous up to 7 days
const windowStart = Math.max(0, idx - 6)
const windowSlice = days.slice(windowStart, idx + 1)
const avg7 = windowSlice.reduce((acc, d) => acc + d.total_hours, 0) / windowSlice.length
const yAvg7 = getY(avg7)
// WHO Compliance
const isWithinWho = totalHours >= 12 && totalHours <= 15
const isBelowWho = totalHours < 12
const isAboveWho = totalHours > 15
return {
date: day.date,
weekday: day.weekday,
formatted_date: day.formatted_date,
is_today: day.is_today,
totalHours,
nightHours,
napHours,
x,
xCenter,
barWidth,
yTotal,
yNight,
nightHeight,
napHeight,
avg7,
yAvg7,
isWithinWho,
isBelowWho,
isAboveWho,
raw: day,
}
})
})
// Moving average path SVG string
const movingAvgPath = computed(() => {
if (bars.value.length < 2) return ''
return bars.value.reduce((acc, b, i) => {
return i === 0 ? `M ${b.xCenter} ${b.yAvg7}` : `${acc} L ${b.xCenter} ${b.yAvg7}`
}, '')
})
// Selected active bar for tooltip scrubber
const activeBar = computed(() => {
if (activeIndex.value === null) {
return bars.value.length ? bars.value[bars.value.length - 1] : null
}
return bars.value[activeIndex.value] || null
})
// Summary metrics over the selected period
const summary = computed(() => {
const days = periodDays.value
if (!days.length) return { avgTotal: '0', avgNight: '0', avgNaps: '0', whoPercent: 0, napCountAvg: '0' }
let sumTot = 0
let sumNight = 0
let sumNaps = 0
let sumNapCount = 0
let inWhoCount = 0
for (const b of bars.value) {
sumTot += b.totalHours
sumNight += b.nightHours
sumNaps += b.napHours
const napSessions = (b.raw.sessions || []).filter(s => s.type !== 'night')
sumNapCount += napSessions.length
if (b.isWithinWho) inWhoCount++
}
const n = bars.value.length
return {
avgTotal: (sumTot / n).toFixed(1),
avgNight: (sumNight / n).toFixed(1),
avgNaps: (sumNaps / n).toFixed(1),
napCountAvg: (sumNapCount / n).toFixed(1),
whoPercent: Math.round((inWhoCount / n) * 100),
}
})
// 24h Hypnogram Raster Barcode data
const rasterDays = computed(() => {
// Newest at top for barcode
return props.history.slice(0, activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30)
})
function getMinuteFromTime(timeStr: string): number {
if (!timeStr) return 0
const parts = timeStr.split(':')
if (parts.length < 2) return 0
return parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)
}
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = relativeX / svg.width
const svgX = ratio * chartWidth
let closestIdx = 0
let closestDist = Infinity
bars.value.forEach((b, idx) => {
const dist = Math.abs(b.xCenter - svgX)
if (dist < closestDist) {
closestDist = dist
closestIdx = idx
}
})
activeIndex.value = closestIdx
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with View & Period Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-purple-500/15 border border-purple-500/30 flex items-center justify-center text-purple-300">
<Moon class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">Schlafarchitektur & Wissenschaft</h3>
<p class="text-xs text-zinc-400">Trend, 24h-Hypnogramm & pädiatrische Normen</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- View Mode Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
@click="activeView = 'trend'"
:class="activeView === 'trend' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<BarChart2 class="w-3.5 h-3.5" />
<span>Trend</span>
</button>
<button
@click="activeView = 'pattern'"
:class="activeView === 'pattern' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Calendar class="w-3.5 h-3.5" />
<span>24h-Muster</span>
</button>
<button
@click="activeView = 'norms'"
:class="activeView === 'norms' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg transition"
>
<Award class="w-3.5 h-3.5" />
<span>Normen</span>
</button>
</div>
<!-- Period Switcher -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold">
<button
v-for="p in (['7d', '14d', '30d'] as const)"
:key="p"
@click="activePeriod = p; activeIndex = null"
:class="activePeriod === p ? 'bg-zinc-800 text-purple-300 shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-2.5 py-1 rounded-lg transition"
>
{{ p === '7d' ? '7T' : p === '14d' ? '14T' : '30T' }}
</button>
</div>
</div>
</div>
<!-- ================= VIEW 1: TREND BAR CHART ================= -->
<div v-if="activeView === 'trend'" class="space-y-4">
<!-- Active Tooltip / Scrubber Inspector Card -->
<div v-if="activeBar" class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-zinc-200 text-sm">
{{ activeBar.weekday }}, {{ activeBar.formatted_date }}
</span>
<span v-if="activeBar.is_today" class="px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-300 border border-purple-500/30 text-[10px] font-semibold">
Heute
</span>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-4 font-mono">
<div class="flex items-center gap-1.5">
<span class="text-zinc-400 font-sans">Gesamt:</span>
<span class="font-bold text-purple-300 text-sm">{{ activeBar.totalHours.toFixed(1) }} h</span>
</div>
<div class="flex items-center gap-1.5 text-indigo-300">
<span>🌙</span>
<span>{{ activeBar.nightHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-emerald-300">
<span>🌿</span>
<span>{{ activeBar.napHours.toFixed(1) }}h</span>
</div>
<div class="flex items-center gap-1.5 text-amber-300 hidden sm:flex">
<span class="text-zinc-400 font-sans">Ø7T:</span>
<span>{{ activeBar.avg7.toFixed(1) }}h</span>
</div>
<span
:class="activeBar.isWithinWho ? 'bg-purple-500/15 border-purple-500/30 text-purple-300' : (activeBar.isBelowWho ? 'bg-amber-500/15 border-amber-500/30 text-amber-300' : 'bg-sky-500/15 border-sky-500/30 text-sky-300')"
class="px-2 py-0.5 rounded-md border text-[11px] font-sans font-medium hidden sm:inline-block"
>
{{ activeBar.isWithinWho ? 'WHO Norm (12–15h)' : (activeBar.isBelowWho ? '< 12h' : '> 15h') }}
</span>
</div>
</div>
<!-- Interactive SVG Chart Area -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal grid lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(18)" :y2="getY(18)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(15)" :y2="getY(15)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(12)" :y2="getY(12)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getY(6)" :y2="getY(6)" />
</g>
<!-- WHO Recommended Band (12h to 15h) -->
<rect
:x="paddingLeft"
:y="whoYTop"
:width="innerWidth"
:height="whoBandHeight"
fill="#a855f7"
fill-opacity="0.08"
stroke="#a855f7"
stroke-opacity="0.3"
stroke-width="1"
stroke-dasharray="3 3"
/>
<text
:x="chartWidth - paddingRight - 4"
:y="whoYTop + 11"
text-anchor="end"
class="fill-purple-400/70 text-[9px] font-mono font-semibold"
>
WHO Richtwert (12–15h)
</text>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<text :x="paddingLeft - 6" :y="getY(18) + 3">18h</text>
<text :x="paddingLeft - 6" :y="getY(15) + 3">15h</text>
<text :x="paddingLeft - 6" :y="getY(12) + 3">12h</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6h</text>
<text :x="paddingLeft - 6" :y="getY(0) - 2">0h</text>
</g>
<!-- Stacked Bars -->
<g v-for="(bar, idx) in bars" :key="bar.date">
<rect
v-if="activeBar?.date === bar.date"
:x="bar.x - 3"
:y="paddingTop"
:width="bar.barWidth + 6"
:height="innerHeight"
fill="#ffffff"
fill-opacity="0.06"
rx="6"
/>
<!-- Night Sleep Segment (Bottom) -->
<rect
:x="bar.x"
:y="bar.yNight"
:width="bar.barWidth"
:height="bar.nightHeight"
class="fill-indigo-500 hover:fill-indigo-400 transition"
rx="3"
/>
<!-- Day Nap Segment (Top of stack) -->
<rect
v-if="bar.napHeight > 0"
:x="bar.x"
:y="bar.yTotal"
:width="bar.barWidth"
:height="bar.napHeight"
class="fill-emerald-400 hover:fill-emerald-300 transition"
rx="3"
/>
<!-- Today Indicator Marker dot -->
<circle
v-if="bar.is_today"
:cx="bar.xCenter"
:cy="bar.yTotal - 6"
r="3"
class="fill-purple-400 animate-pulse"
/>
<!-- X-Axis Labels (Date / Weekday) -->
<text
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
:class="bar.is_today ? 'fill-purple-300 font-bold' : (activeBar?.date === bar.date ? 'fill-zinc-200 font-bold' : 'fill-zinc-500')"
class="text-[9px] font-mono"
>
{{ bar.weekday }}
</text>
<text
v-if="activePeriod !== '30d' || idx % 3 === 0"
:x="bar.xCenter"
:y="chartHeight - paddingBottom + 25"
text-anchor="middle"
class="fill-zinc-600 text-[8px] font-mono"
>
{{ bar.date.slice(8) }}
</text>
</g>
<!-- Moving Average 7-Day Trend Line -->
<path
v-if="movingAvgPath"
:d="movingAvgPath"
fill="none"
stroke="#f59e0b"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="opacity-80"
/>
</svg>
</div>
<!-- Chart Legend & Science Summary -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-3 sm:gap-4 flex-wrap">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-indigo-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">Nacht</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-emerald-400 shrink-0"></span>
<span class="text-zinc-300 font-medium">Naps</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-4 h-0.5 bg-amber-500 shrink-0"></span>
<span class="text-amber-300 font-medium">7T-Schnitt</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-purple-500/20 border border-purple-500/40 shrink-0"></span>
<span class="text-purple-300 font-medium">WHO (12–15h)</span>
</div>
</div>
<div class="flex items-center gap-3 font-mono text-xs">
<span class="text-zinc-400">Ø Gesamt: <strong class="text-purple-300">{{ summary.avgTotal }}h</strong></span>
<span class="text-zinc-400">Norm: <strong class="text-emerald-400">{{ summary.whoPercent }}%</strong></span>
</div>
</div>
</div>
<!-- ================= VIEW 2: 24h PATTERN / HYPNOGRAM RASTER ================= -->
<div v-else-if="activeView === 'pattern'" class="space-y-3">
<div class="text-xs text-zinc-400 flex items-center justify-between">
<span>Täglicher Schlafverlauf von 00:00 bis 24:00 Uhr</span>
<div class="flex items-center gap-3">
<span class="flex items-center gap-1 text-indigo-300">
<span class="w-2 h-2 rounded-full bg-indigo-500"></span> Nacht
</span>
<span class="flex items-center gap-1 text-emerald-300">
<span class="w-2 h-2 rounded-full bg-emerald-400"></span> Tagesschläfchen
</span>
</div>
</div>
<!-- Time Header Strip (0h, 4h, 8h, 12h, 16h, 20h, 24h) -->
<div class="flex items-center text-[10px] text-zinc-500 font-mono pl-20 pr-2">
<div class="flex-1 text-left">00:00</div>
<div class="flex-1 text-center">06:00</div>
<div class="flex-1 text-center">12:00</div>
<div class="flex-1 text-center">18:00</div>
<div class="w-8 text-right">24:00</div>
</div>
<!-- Raster Bars -->
<div class="space-y-1.5">
<div
v-for="day in rasterDays"
:key="day.date"
class="flex items-center gap-2 group"
>
<!-- Date Label -->
<div class="w-18 shrink-0 font-mono text-[11px] text-zinc-400 group-hover:text-zinc-200 flex items-center gap-1">
<span :class="day.is_today ? 'text-purple-400 font-bold' : ''">{{ day.weekday }}</span>
<span class="text-[10px] text-zinc-500">{{ day.date.slice(5) }}</span>
</div>
<!-- 24h Timeline Track -->
<div class="flex-1 h-6 bg-zinc-950/80 rounded-lg relative overflow-hidden border border-zinc-800/80 group-hover:border-zinc-700">
<!-- 6h Guide Gridlines -->
<div class="absolute inset-0 flex justify-between pointer-events-none opacity-20">
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
<div class="border-r border-zinc-600 h-full w-1/4"></div>
</div>
<!-- Sessions Blocks -->
<template v-if="day.sessions && day.sessions.length > 0">
<template v-for="(s, sIdx) in day.sessions" :key="sIdx">
<!-- If valid start time -->
<div
v-if="s.start"
:style="{
left: `${(getMinuteFromTime(s.start) / 1440) * 100}%`,
width: `${Math.max(1.2, ((s.duration_minutes || 60) / 1440) * 100)}%`,
}"
:class="s.type === 'night' ? 'bg-indigo-600/90 border-indigo-400/50 text-indigo-100' : 'bg-emerald-500/90 border-emerald-300/50 text-emerald-100'"
class="absolute top-0 bottom-0 rounded-[3px] border border-opacity-40 flex items-center justify-center text-[9px] font-mono font-bold overflow-hidden cursor-help"
:title="`${s.type === 'night' ? 'Nachtschlaf' : 'Nap'}: ${s.start}–${s.end || 'jetzt'} (${s.duration_minutes} min)`"
>
<span v-if="(s.duration_minutes || 0) >= 45" class="truncate px-0.5">
{{ s.duration_minutes }}m
</span>
</div>
</template>
</template>
<div v-else class="text-[10px] text-zinc-600 italic px-2 py-0.5">Keine Einzelschläfchen</div>
</div>
</div>
</div>
</div>
<!-- ================= VIEW 3: NORMS & PEDIATRIC CORRIDORS ================= -->
<div v-else-if="activeView === 'norms'" class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<!-- Card 1: 24h Gesamt-Schlaf -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">24h Gesamtschlaf</span>
<span class="text-purple-300 font-mono font-bold">{{ summary.avgTotal }}h / 12–15h</span>
</div>
<!-- Visual corridor progress bar -->
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor shaded region (12h to 15h in 18h scale: 66% to 83%) -->
<div class="absolute left-[66%] right-[16%] top-0 bottom-0 bg-purple-500/20 border-x border-purple-500/50"></div>
<!-- Mila actual marker -->
<div
:style="{ left: `${Math.min(100, (Number(summary.avgTotal) / 18) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-purple-400 rounded-full shadow-lg shadow-purple-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>WHO / AASM Richtwert (12–15h):</strong> Unterstützt die kognitive Konsolidierung, das Längenwachstum durch Wachstumshormone im Tiefschlaf und beugt Übermüdung vor.
</p>
</div>
<!-- Card 2: Nachtschlaf-Architektur -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nachtschlaf</span>
<span class="text-indigo-300 font-mono font-bold">{{ summary.avgNight }}h / 10–11.5h</span>
</div>
<div class="w-full h-3 bg-zinc-900 rounded-full overflow-hidden relative border border-zinc-800">
<!-- Norm corridor (10h to 11.5h in 14h scale) -->
<div class="absolute left-[71%] right-[17%] top-0 bottom-0 bg-indigo-500/20 border-x border-indigo-500/50"></div>
<div
:style="{ left: `${Math.min(100, (Number(summary.avgNight) / 14) * 100)}%` }"
class="absolute top-0 bottom-0 w-2 -ml-1 bg-indigo-400 rounded-full shadow-lg shadow-indigo-400/50"
></div>
</div>
<p class="text-[11px] text-zinc-400 pt-1 leading-relaxed">
<strong>Remo H. Largo (Babyjahre):</strong> Im 2. Lebenshalbjahr konsolidiert sich der Nachtschlaf auf ca. 10–11 Stunden am Stück (mit altersgemäßen Aufwachphasen zur Nahrung/Rückversicherung).
</p>
</div>
<!-- Card 3: Tagesschläfchen-Frequenz -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Nap-Frequenz</span>
<span class="text-emerald-300 font-mono font-bold">{{ summary.napCountAvg }} Naps / 2–3 Naps</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Entwicklungsübergang (6–9 Monate):</strong> Die meisten Babys wechseln von 3 kurzen Nickerchen zu 2 stabilen Schläfchen (Vormittag + Nachmittag). Mila liegt mit Ø {{ summary.napCountAvg }} Naps voll im Normbereich.
</p>
</div>
<!-- Card 4: Wachfenster-Korridor -->
<div class="p-4 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 space-y-2">
<div class="flex items-center justify-between text-xs">
<span class="font-bold text-zinc-300 uppercase tracking-wider font-mono">Sweet-Spot Wachfenster</span>
<span class="text-amber-300 font-mono font-bold">2h 15m – 3h 00m</span>
</div>
<p class="text-[11px] text-zinc-400 leading-relaxed">
<strong>Schlafdruck vs. Übermüdung:</strong> Nach 2h 15m baut sich der ideale Schlafdruck auf. Ab 3h 15m steigt der Cortisol- und Adrenalinspiegel, was das Einschlafen erschwert.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,579 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Moon, Sun, Baby, Milk, HeartPulse, Check, X } from 'lucide-vue-next'
import { logEvent, stopSleepEvent, getLocalDateString } from '../api'
const props = defineProps<{
isOpen: boolean
isSleeping: boolean
activeSleepId?: string
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'logged'): void
}>()
const activeTab = ref<'sleep' | 'feeding' | 'diaper' | 'growth'>('sleep')
const feedingSubTab = ref<'bottle' | 'solid'>('bottle')
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
// --- Sleep Form ---
const sleepType = ref<'nap' | 'night'>('nap')
const sleepLocation = ref('bett')
const sleepStartTime = ref('')
const sleepEndTime = ref('')
const sleepNote = ref('')
// --- Bottle Form ---
const bottleMl = ref<number | ''>(150)
const bottleMilkType = ref('Pre')
const bottleTime = ref('')
const bottleNote = ref('')
// --- Solid Form ---
const solidItem = ref('')
const solidGrams = ref<number | ''>(100)
const solidKcal = ref<number | ''>(70)
const solidTime = ref('')
const solidNote = ref('')
// --- Diaper Form ---
const diaperTexture = ref('weich')
const diaperNote = ref('')
// --- Growth Form ---
const growthWeight = ref<number | ''>(7.2)
const growthHeight = ref<number | ''>(66.0)
const growthHeadCirc = ref<number | ''>(42.5)
const growthNote = ref('')
const targetDate = ref(getLocalDateString())
function resetMessages() {
errorMessage.value = null
successMessage.value = null
}
async function handleStartSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
date: targetDate.value || undefined,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schläfchen gestartet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Starten des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleStopSleepNow() {
resetMessages()
isSubmitting.value = true
try {
await stopSleepEvent({
subject: 'mila',
event_id: props.activeSleepId,
})
successMessage.value = 'Schläfchen erfolgreich beendet!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Beenden des Schläfchens'
} finally {
isSubmitting.value = false
}
}
async function handleCustomSleepSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
let startIso: string | undefined = undefined
let endIso: string | undefined = undefined
if (sleepStartTime.value) {
startIso = `${baseDate}T${sleepStartTime.value}:00`
}
if (sleepEndTime.value) {
if (sleepStartTime.value && sleepEndTime.value < sleepStartTime.value) {
// Crosses midnight: end timestamp is next day
const [y, m, d] = baseDate.split('-').map(Number)
const nextDay = new Date(y, m - 1, d + 1)
const ny = nextDay.getFullYear()
const nm = String(nextDay.getMonth() + 1).padStart(2, '0')
const nd = String(nextDay.getDate()).padStart(2, '0')
endIso = `${ny}-${nm}-${nd}T${sleepEndTime.value}:00`
} else {
endIso = `${baseDate}T${sleepEndTime.value}:00`
}
}
await logEvent({
kind: 'sleep.session',
subject: 'mila',
payload: { type: sleepType.value, location: sleepLocation.value },
start: startIso,
end: endIso,
date: baseDate,
note: sleepNote.value || undefined,
})
successMessage.value = 'Schlaf eingetragen!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleFeedingSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
if (feedingSubTab.value === 'bottle') {
const startIso = bottleTime.value ? `${baseDate}T${bottleTime.value}:00` : undefined
await logEvent({
kind: 'feeding.bottle',
subject: 'mila',
payload: {
consumed_ml: Number(bottleMl.value) || 0,
milk_type: bottleMilkType.value,
},
start: startIso,
date: baseDate,
note: bottleNote.value || undefined,
})
successMessage.value = `${bottleMl.value} ml Fläschchen eingetragen!`
} else {
const startIso = solidTime.value ? `${baseDate}T${solidTime.value}:00` : undefined
await logEvent({
kind: 'feeding.solid',
subject: 'mila',
payload: {
item: solidItem.value || 'Brei',
consumed_g: Number(solidGrams.value) || 0,
kcal: Number(solidKcal.value) || 0,
},
start: startIso,
date: baseDate,
note: solidNote.value || undefined,
})
successMessage.value = `${solidGrams.value} g Brei eingetragen!`
}
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleDiaperQuickSubmit(pee: boolean, poo: boolean) {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'diaper.change',
subject: 'mila',
payload: {
pee,
poo,
texture: poo ? diaperTexture.value : undefined,
},
date: baseDate,
note: diaperNote.value || undefined,
})
successMessage.value = 'Windelwechsel erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
async function handleGrowthSubmit() {
resetMessages()
isSubmitting.value = true
try {
const baseDate = targetDate.value || getLocalDateString()
await logEvent({
kind: 'growth.measurement',
subject: 'mila',
payload: {
weight_kg: Number(growthWeight.value) || null,
height_cm: Number(growthHeight.value) || null,
head_circ_cm: Number(growthHeadCirc.value) || null,
},
date: baseDate,
note: growthNote.value || undefined,
})
successMessage.value = 'Messung erfasst!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Eintragen'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/70 backdrop-blur-md">
<div class="w-full sm:max-w-lg bg-zinc-950 border border-zinc-800 rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
<!-- Modal Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-zinc-800 bg-zinc-900/50">
<div class="flex items-center gap-2">
<span class="text-xl">✨</span>
<h2 class="text-base font-bold text-white font-heading">Ereignis erfassen</h2>
</div>
<button @click="emit('close')" class="p-1 rounded-xl text-zinc-400 hover:text-white hover:bg-zinc-800 transition">
<X class="w-5 h-5" />
</button>
</div>
<!-- Tab Navigation -->
<div class="grid grid-cols-4 p-1.5 bg-zinc-900 border-b border-zinc-800 text-xs font-semibold">
<button
@click="activeTab = 'sleep'; resetMessages()"
:class="activeTab === 'sleep' ? 'bg-purple-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Moon class="w-3.5 h-3.5" />
<span>Schlaf</span>
</button>
<button
@click="activeTab = 'feeding'; resetMessages()"
:class="activeTab === 'feeding' ? 'bg-amber-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Milk class="w-3.5 h-3.5" />
<span>Nahrung</span>
</button>
<button
@click="activeTab = 'diaper'; resetMessages()"
:class="activeTab === 'diaper' ? 'bg-teal-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<Baby class="w-3.5 h-3.5" />
<span>Windel</span>
</button>
<button
@click="activeTab = 'growth'; resetMessages()"
:class="activeTab === 'growth' ? 'bg-pink-600 text-white shadow-md' : 'text-zinc-400 hover:text-zinc-200'"
class="flex items-center justify-center gap-1.5 py-2 rounded-xl transition"
>
<HeartPulse class="w-3.5 h-3.5" />
<span>Wachstum</span>
</button>
</div>
<!-- Date Selection Bar (Retroactive Tracking) -->
<div class="px-5 py-2 bg-zinc-900/60 border-b border-zinc-800 flex items-center justify-between text-xs text-zinc-400">
<span class="flex items-center gap-1.5 font-medium">
<span>📅</span>
<span>Ereignis-Datum:</span>
</span>
<input
v-model="targetDate"
type="date"
class="bg-zinc-950 border border-zinc-700/80 rounded-lg px-2.5 py-1 text-xs text-zinc-100 font-mono focus:border-purple-500 focus:outline-none"
/>
</div>
<!-- Messages -->
<div v-if="errorMessage" class="mx-5 mt-4 p-3 rounded-xl bg-red-950/60 border border-red-800/80 text-red-200 text-xs">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="mx-5 mt-4 p-3 rounded-xl bg-emerald-950/60 border border-emerald-800/80 text-emerald-200 text-xs flex items-center gap-2">
<Check class="w-4 h-4 text-emerald-400" />
<span>{{ successMessage }}</span>
</div>
<!-- Tab Content Area -->
<div class="p-5 overflow-y-auto space-y-4 flex-1">
<!-- ================= TAB 1: SCHLAF ================= -->
<div v-if="activeTab === 'sleep'" class="space-y-4">
<!-- 1-Tap Quick Action -->
<div class="p-4 rounded-2xl bg-zinc-900/80 border border-purple-500/20 space-y-3">
<div class="text-xs font-semibold text-purple-300 uppercase tracking-wider">1-Tap Sofort-Logging</div>
<div v-if="!isSleeping" class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<button
@click="sleepType = 'nap'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-500 hover:to-indigo-500 text-white font-bold text-sm flex items-center justify-center gap-2 shadow-lg shadow-purple-600/20 active:scale-95 transition"
>
<Moon class="w-4 h-4" />
<span>Schläfchen JETZT starten</span>
</button>
<button
@click="sleepType = 'night'; handleStartSleepNow()"
:disabled="isSubmitting"
class="w-full py-3 px-4 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-sky-300 border border-sky-500/30 font-semibold text-sm flex items-center justify-center gap-2 active:scale-95 transition"
>
<span>🌙 Nachtschlaf starten</span>
</button>
</div>
<div v-else class="space-y-2">
<button
@click="handleStopSleepNow()"
:disabled="isSubmitting"
class="w-full py-3.5 px-4 rounded-xl bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 text-white font-extrabold text-sm flex items-center justify-center gap-2 shadow-lg shadow-emerald-600/20 active:scale-95 transition animate-pulse"
>
<Sun class="w-4 h-4" />
<span>Schlaf JETZT beenden (Aufgewacht!)</span>
</button>
</div>
</div>
<!-- Custom Sleep Form -->
<form @submit.prevent="handleCustomSleepSubmit" class="space-y-3 pt-2 border-t border-zinc-800">
<div class="text-xs font-semibold text-zinc-400">Oder manuell mit Uhrzeit:</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Typ</label>
<select v-model="sleepType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="nap">Tagesschläfchen (Nap)</option>
<option value="night">Nachtschlaf</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Ort</label>
<select v-model="sleepLocation" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="bett">Bett</option>
<option value="kinderwagen">Kinderwagen</option>
<option value="auto">Auto</option>
<option value="trage">Trage</option>
<option value="sofa">Sofa</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Startzeit</label>
<input v-model="sleepStartTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Endzeit (optional)</label>
<input v-model="sleepEndTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="sleepNote" type="text" placeholder="z.B. schnell eingeschlafen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full py-2.5 rounded-xl bg-purple-700 hover:bg-purple-600 text-white font-semibold text-xs transition"
>
Schlaf manuell speichern
</button>
</form>
</div>
<!-- ================= TAB 2: NAHRUNG ================= -->
<div v-if="activeTab === 'feeding'" class="space-y-4">
<!-- Sub-tab Toggle -->
<div class="flex rounded-xl bg-zinc-900 p-1 border border-zinc-800 text-xs">
<button
@click="feedingSubTab = 'bottle'"
:class="feedingSubTab === 'bottle' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🍼 Fläschchen
</button>
<button
@click="feedingSubTab = 'solid'"
:class="feedingSubTab === 'solid' ? 'bg-orange-500/20 text-orange-300 border border-orange-500/30' : 'text-zinc-400'"
class="flex-1 py-1.5 rounded-lg font-semibold transition"
>
🥣 Brei / Beikost
</button>
</div>
<!-- Bottle Section -->
<div v-if="feedingSubTab === 'bottle'" class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl (ml)</label>
<div class="grid grid-cols-6 gap-1.5">
<button
v-for="ml in [60, 90, 120, 150, 180, 210]"
:key="ml"
type="button"
@click="bottleMl = ml"
:class="bottleMl === ml ? 'bg-amber-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ ml }}
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Getrunkene Menge (ml)</label>
<input v-model="bottleMl" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Milchtyp</label>
<select v-model="bottleMilkType" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="Pre">Pre</option>
<option value="Muttermilch">Muttermilch</option>
<option value="1er">1er Nahrung</option>
</select>
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Uhrzeit (leer = Jetzt)</label>
<input v-model="bottleTime" type="time" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-500 hover:to-orange-500 text-white font-bold text-sm shadow-lg shadow-amber-600/20 active:scale-95 transition"
>
Flasche speichern
</button>
</div>
<!-- Solid Section -->
<div v-else class="space-y-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Brei-Bezeichnung</label>
<input v-model="solidItem" type="text" placeholder="z.B. Kürbis-Kartoffel Brei" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Schnellwahl Gramm</label>
<div class="grid grid-cols-4 gap-2">
<button
v-for="g in [50, 100, 150, 190]"
:key="g"
type="button"
@click="solidGrams = g"
:class="solidGrams === g ? 'bg-orange-500 text-zinc-950 font-bold' : 'bg-zinc-900 text-zinc-300 border border-zinc-800'"
class="py-2 rounded-xl text-xs font-mono transition"
>
{{ g }}g
</button>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Menge (g)</label>
<input v-model="solidGrams" type="number" step="10" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kalorien (~kcal)</label>
<input v-model="solidKcal" type="number" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<button
@click="handleFeedingSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-500 hover:to-amber-500 text-white font-bold text-sm shadow-lg shadow-orange-600/20 active:scale-95 transition"
>
Brei speichern
</button>
</div>
</div>
<!-- ================= TAB 3: WINDEL ================= -->
<div v-if="activeTab === 'diaper'" class="space-y-4">
<div class="text-xs font-semibold text-teal-300 uppercase tracking-wider">1-Tap Windelwechsel</div>
<div class="grid grid-cols-3 gap-2">
<button
@click="handleDiaperQuickSubmit(true, false)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-teal-500/30 hover:border-teal-500/60 hover:bg-teal-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💧</span>
<span class="text-xs font-bold text-teal-300">Nur Pipi</span>
</button>
<button
@click="handleDiaperQuickSubmit(false, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-amber-500/30 hover:border-amber-500/60 hover:bg-amber-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">💩</span>
<span class="text-xs font-bold text-amber-300">Nur Kacka</span>
</button>
<button
@click="handleDiaperQuickSubmit(true, true)"
:disabled="isSubmitting"
class="p-4 rounded-2xl bg-zinc-900 border border-emerald-500/30 hover:border-emerald-500/60 hover:bg-emerald-500/10 flex flex-col items-center gap-1.5 transition active:scale-95"
>
<span class="text-2xl">🧷</span>
<span class="text-xs font-bold text-emerald-300">Beides</span>
</button>
</div>
<div class="space-y-3 pt-3 border-t border-zinc-800">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Konsistenz (optional)</label>
<select v-model="diaperTexture" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white">
<option value="weich">Weich</option>
<option value="breiig">Breiig</option>
<option value="fest">Fest</option>
<option value="flüssig">Flüssig</option>
</select>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="diaperNote" type="text" placeholder="z.B. nach dem Aufwachen" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
</div>
</div>
<!-- ================= TAB 4: WACHSTUM ================= -->
<div v-if="activeTab === 'growth'" class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Gewicht (kg)</label>
<input v-model="growthWeight" type="number" step="0.05" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Größe (cm)</label>
<input v-model="growthHeight" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Kopf (cm)</label>
<input v-model="growthHeadCirc" type="number" step="0.5" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white font-mono" />
</div>
</div>
<div>
<label class="text-[11px] text-zinc-400 block mb-1">Notiz</label>
<input v-model="growthNote" type="text" placeholder="z.B. U5 Untersuchung beim Kinderarzt" class="w-full bg-zinc-900 border border-zinc-700 rounded-xl px-3 py-2 text-xs text-white" />
</div>
<button
@click="handleGrowthSubmit"
:disabled="isSubmitting"
class="w-full py-3 rounded-xl bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white font-bold text-sm shadow-lg shadow-pink-600/20 active:scale-95 transition"
>
Messung speichern
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { GrowthMeasurement } from '../types'
import { Ruler, Sparkles } from 'lucide-vue-next'
const props = defineProps<{
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
currentAgeMonths?: number
}>()
const activeMetric = ref<'weight' | 'height'>('weight')
const activeMonthHover = ref<number | null>(null)
// WHO Girls Growth Standards 0-18 Months
const WHO_WEIGHT = [
{ m: 0, p3: 2.4, p15: 2.8, p50: 3.2, p85: 3.7, p97: 4.2 },
{ m: 1, p3: 3.2, p15: 3.6, p50: 4.2, p85: 4.8, p97: 5.4 },
{ m: 2, p3: 4.0, p15: 4.5, p50: 5.1, p85: 5.8, p97: 6.5 },
{ m: 3, p3: 4.6, p15: 5.2, p50: 5.8, p85: 6.6, p97: 7.4 },
{ m: 4, p3: 5.1, p15: 5.7, p50: 6.4, p85: 7.3, p97: 8.1 },
{ m: 5, p3: 5.5, p15: 6.1, p50: 6.9, p85: 7.8, p97: 8.7 },
{ m: 6, p3: 5.8, p15: 6.5, p50: 7.3, p85: 8.2, p97: 9.2 },
{ m: 7, p3: 6.1, p15: 6.8, p50: 7.6, p85: 8.6, p97: 9.6 },
{ m: 8, p3: 6.3, p15: 7.0, p50: 7.9, p85: 8.9, p97: 10.0 },
{ m: 9, p3: 6.6, p15: 7.3, p50: 8.2, p85: 9.3, p97: 10.4 },
{ m: 10, p3: 6.8, p15: 7.5, p50: 8.5, p85: 9.6, p97: 10.7 },
{ m: 11, p3: 7.0, p15: 7.7, p50: 8.7, p85: 9.9, p97: 11.0 },
{ m: 12, p3: 7.1, p15: 7.9, p50: 8.9, p85: 10.1, p97: 11.3 },
{ m: 15, p3: 7.7, p15: 8.5, p50: 9.6, p85: 10.9, p97: 12.2 },
{ m: 18, p3: 8.2, p15: 9.1, p50: 10.2, p85: 11.6, p97: 13.0 },
]
const WHO_HEIGHT = [
{ m: 0, p3: 45.6, p15: 47.3, p50: 49.1, p85: 51.0, p97: 52.7 },
{ m: 1, p3: 50.0, p15: 51.7, p50: 53.7, p85: 55.6, p97: 57.4 },
{ m: 2, p3: 53.2, p15: 55.0, p50: 57.1, p85: 59.1, p97: 60.9 },
{ m: 3, p3: 55.8, p15: 57.7, p50: 59.8, p85: 61.9, p97: 63.8 },
{ m: 4, p3: 58.0, p15: 59.9, p50: 62.1, p85: 64.3, p97: 66.2 },
{ m: 5, p3: 59.9, p15: 61.8, p50: 64.0, p85: 66.3, p97: 68.2 },
{ m: 6, p3: 61.5, p15: 63.5, p50: 65.7, p85: 68.0, p97: 70.0 },
{ m: 7, p3: 62.9, p15: 65.0, p50: 67.3, p85: 69.6, p97: 71.6 },
{ m: 8, p3: 64.3, p15: 66.4, p50: 68.7, p85: 71.1, p97: 73.2 },
{ m: 9, p3: 65.6, p15: 67.8, p50: 70.1, p85: 72.5, p97: 74.7 },
{ m: 10, p3: 66.8, p15: 69.0, p50: 71.5, p85: 73.9, p97: 76.1 },
{ m: 11, p3: 68.0, p15: 70.3, p50: 72.8, p85: 75.2, p97: 77.5 },
{ m: 12, p3: 69.2, p15: 71.4, p50: 74.0, p85: 76.5, p97: 78.9 },
{ m: 15, p3: 72.4, p15: 74.8, p50: 77.5, p85: 80.2, p97: 82.7 },
{ m: 18, p3: 75.2, p15: 77.8, p50: 80.7, p85: 83.5, p97: 86.2 },
]
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 38
const paddingRight = 20
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
const maxMonth = 18
// Min/Max Y scale based on active metric
const yMin = computed(() => (activeMetric.value === 'weight' ? 2 : 44))
const yMax = computed(() => (activeMetric.value === 'weight' ? 14 : 90))
function getX(month: number): number {
const clamped = Math.max(0, Math.min(maxMonth, month))
return paddingLeft + (clamped / maxMonth) * innerWidth
}
function getY(val: number): number {
const clamped = Math.max(yMin.value, Math.min(yMax.value, val))
return paddingTop + innerHeight - ((clamped - yMin.value) / (yMax.value - yMin.value)) * innerHeight
}
const currentTable = computed(() => (activeMetric.value === 'weight' ? WHO_WEIGHT : WHO_HEIGHT))
// Path builder for WHO percentile lines
function buildPath(key: 'p3' | 'p15' | 'p50' | 'p85' | 'p97'): string {
const pts = currentTable.value.map((row) => `${getX(row.m).toFixed(1)},${getY(row[key]).toFixed(1)}`)
return `M ${pts.join(' L ')}`
}
// Shaded polygon area between P15 and P85
const normalBandPath = computed(() => {
const topPts = currentTable.value.map((r) => `${getX(r.m).toFixed(1)},${getY(r.p85).toFixed(1)}`)
const botPts = [...currentTable.value].reverse().map((r) => `${getX(r.m).toFixed(1)},${getY(r.p15).toFixed(1)}`)
return `M ${topPts.join(' L ')} L ${botPts.join(' L ')} Z`
})
// Mila's plotted points
const plottedPoints = computed(() => {
return props.measurements
.filter((m) => (activeMetric.value === 'weight' ? m.weight_kg !== null : m.height_cm !== null))
.map((m) => {
const val = (activeMetric.value === 'weight' ? m.weight_kg : m.height_cm) as number
const pctStr = activeMetric.value === 'weight' ? m.weight_pct : m.height_pct
return {
id: m.id,
date_pretty: m.date_pretty,
age_str: m.age_str,
age_months: m.age_months,
val,
pctStr,
cx: getX(m.age_months),
cy: getY(val),
}
})
})
// Active inspected month info for hover/touch
const inspectedData = computed(() => {
const m = activeMonthHover.value ?? (props.currentAgeMonths ? Math.round(props.currentAgeMonths) : 7)
const clampedM = Math.max(0, Math.min(maxMonth, m))
// Find nearest WHO row
let nearest = currentTable.value[0]
let minDist = Infinity
for (const row of currentTable.value) {
const d = Math.abs(row.m - clampedM)
if (d < minDist) {
minDist = d
nearest = row
}
}
// Find if Mila has a measurement near this month
const milaPt = plottedPoints.value.find((pt) => Math.abs(pt.age_months - clampedM) <= 1)
return {
month: clampedM,
p50: nearest.p50,
p15: nearest.p15,
p85: nearest.p85,
unit: activeMetric.value === 'weight' ? 'kg' : 'cm',
milaPt,
}
})
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = (relativeX / svg.width) * chartWidth
const month = ((ratio - paddingLeft) / innerWidth) * maxMonth
activeMonthHover.value = Math.max(0, Math.min(maxMonth, Math.round(month)))
}
</script>
<template>
<div class="bg-zinc-900/80 border border-zinc-800/90 rounded-3xl p-5 sm:p-6 shadow-xl space-y-4">
<!-- Header with Metric Switcher -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-zinc-800/70">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-pink-500/15 border border-pink-500/30 flex items-center justify-center text-pink-300">
<Ruler class="w-4 h-4" />
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-white font-heading">WHO Wachstums- & Perzentilenkurven</h3>
<p class="text-xs text-zinc-400">Offizielle WHO-Standards für Mädchen (0–18 Monate)</p>
</div>
</div>
<!-- Toggle Gewicht vs Größe -->
<div class="inline-flex rounded-xl bg-zinc-950 p-1 border border-zinc-800 text-xs font-semibold self-start sm:self-auto">
<button
@click="activeMetric = 'weight'"
:class="activeMetric === 'weight' ? 'bg-pink-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
⚖️ Gewicht (kg)
</button>
<button
@click="activeMetric = 'height'"
:class="activeMetric === 'height' ? 'bg-purple-600 text-white shadow-sm' : 'text-zinc-400 hover:text-zinc-200'"
class="px-3 py-1 rounded-lg transition"
>
📏 Körpergröße (cm)
</button>
</div>
</div>
<!-- Active Scrubber Inspector Card -->
<div class="p-3.5 rounded-2xl bg-zinc-950/70 border border-zinc-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<Sparkles class="w-4 h-4 text-pink-400 shrink-0" />
<span class="font-bold text-zinc-200">
Alter: {{ inspectedData.month }} Monate
</span>
<span class="text-zinc-400">· WHO P50 Median:</span>
<strong class="font-mono text-purple-300">{{ inspectedData.p50 }} {{ inspectedData.unit }}</strong>
</div>
<div class="flex flex-wrap items-center gap-2 sm:gap-3 font-mono">
<span class="text-zinc-400">
Normalbereich (P15–P85):
<strong class="text-zinc-300">{{ inspectedData.p15 }} – {{ inspectedData.p85 }} {{ inspectedData.unit }}</strong>
</span>
<span
v-if="inspectedData.milaPt"
class="px-2 py-0.5 rounded-md bg-pink-500/20 border border-pink-500/40 text-pink-300 font-semibold"
>
Mila: {{ inspectedData.milaPt.val }} {{ inspectedData.unit }} ({{ inspectedData.milaPt.pctStr }})
</span>
</div>
</div>
<!-- Interactive SVG Curve Canvas -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal Grid Lines -->
<g stroke="#27272a" stroke-width="1" stroke-dasharray="2 3">
<line
v-for="yTick in (activeMetric === 'weight' ? [3, 6, 9, 12] : [50, 60, 70, 80])"
:key="yTick"
:x1="paddingLeft"
:x2="chartWidth - paddingRight"
:y1="getY(yTick)"
:y2="getY(yTick)"
/>
</g>
<!-- Shaded Normal Range Corridor (P15 to P85) -->
<path
:d="normalBandPath"
fill="#ec4899"
fill-opacity="0.08"
/>
<!-- P3 Curve (Lower bound) -->
<path
:d="buildPath('p3')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- P15 Curve -->
<path
:d="buildPath('p15')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P50 Median Curve (Prominent) -->
<path
:d="buildPath('p50')"
fill="none"
stroke="#a855f7"
stroke-width="2.5"
stroke-linecap="round"
/>
<!-- P85 Curve -->
<path
:d="buildPath('p85')"
fill="none"
stroke="#ec4899"
stroke-width="1"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- P97 Curve (Upper bound) -->
<path
:d="buildPath('p97')"
fill="none"
stroke="#f43f5e"
stroke-width="1.2"
stroke-dasharray="3 3"
stroke-opacity="0.6"
/>
<!-- Y-Axis Labels -->
<g class="fill-zinc-500 text-[10px] font-mono" text-anchor="end">
<template v-if="activeMetric === 'weight'">
<text :x="paddingLeft - 6" :y="getY(14) + 3">14kg</text>
<text :x="paddingLeft - 6" :y="getY(10) + 3">10kg</text>
<text :x="paddingLeft - 6" :y="getY(6) + 3">6kg</text>
<text :x="paddingLeft - 6" :y="getY(2) - 2">2kg</text>
</template>
<template v-else>
<text :x="paddingLeft - 6" :y="getY(85) + 3">85cm</text>
<text :x="paddingLeft - 6" :y="getY(70) + 3">70cm</text>
<text :x="paddingLeft - 6" :y="getY(55) + 3">55cm</text>
</template>
</g>
<!-- Month X-Axis Ticks & Labels -->
<g class="fill-zinc-500 text-[9px] font-mono" text-anchor="middle">
<template v-for="m in [0, 2, 4, 6, 8, 10, 12, 15, 18]" :key="m">
<line
:x1="getX(m)"
:x2="getX(m)"
:y1="chartHeight - paddingBottom"
:y2="chartHeight - paddingBottom + 4"
stroke="#3f3f46"
/>
<text :x="getX(m)" :y="chartHeight - paddingBottom + 16">{{ m }}m</text>
</template>
</g>
<!-- Vertical Scrubber Guideline -->
<line
v-if="activeMonthHover !== null"
:x1="getX(activeMonthHover)"
:x2="getX(activeMonthHover)"
:y1="paddingTop"
:y2="chartHeight - paddingBottom"
stroke="#ffffff"
stroke-width="1.5"
stroke-dasharray="2 2"
stroke-opacity="0.4"
/>
<!-- Mila's Actual Measurement Markers -->
<g v-for="pt in plottedPoints" :key="pt.id">
<!-- Outer glow -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="8"
fill="#ec4899"
fill-opacity="0.25"
class="animate-pulse"
/>
<!-- Core dot -->
<circle
:cx="pt.cx"
:cy="pt.cy"
r="4.5"
fill="#f43f5e"
stroke="#ffffff"
stroke-width="2"
/>
</g>
</svg>
</div>
<!-- Science Legend & Notes -->
<div class="pt-3 border-t border-zinc-800/60 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-1 rounded bg-purple-500 shrink-0"></span>
<span class="text-zinc-300 font-medium">P50 (Median)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-2.5 rounded bg-pink-500/20 border border-pink-500/40 shrink-0"></span>
<span class="text-zinc-300 font-medium">P15–P85 (Normalspanne)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-2.5 h-2.5 rounded-full bg-rose-500 border border-white shrink-0"></span>
<span class="text-pink-300 font-medium">Mila Messpunkt</span>
</div>
</div>
<div class="text-[11px] text-zinc-400">
WHO Child Growth Standards (WHO MGRS)
</div>
</div>
</div>
</template>
+30
View File
@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
// Client-side canonical path enforcement
if (typeof window !== 'undefined' && (window.location.pathname === '' || window.location.pathname === '/')) {
window.location.replace('/tracking/mila/')
}
createApp(App).mount('#app')
// Reliable Service Worker registration (catches readyState complete and load)
if ('serviceWorker' in navigator) {
const registerSW = () => {
navigator.serviceWorker
.register('/tracking/mila/sw.js', { scope: '/tracking/mila/' })
.then((reg) => {
// Check for updates
reg.update().catch(() => {})
})
.catch((err) => console.warn('Mila Service Worker registration failed:', err))
}
if (document.readyState === 'complete') {
registerSW()
} else {
window.addEventListener('load', registerSW)
}
}
+16
View File
@@ -0,0 +1,16 @@
@import "tailwindcss";
@layer base {
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.font-heading {
font-family: 'Syne', sans-serif;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
+159
View File
@@ -0,0 +1,159 @@
export interface NapSession {
id: string
start: string
end: string
sleep_type: string
location: string
note: string
duration_minutes: number | null
is_open: boolean
}
export interface FeedingItem {
id?: string
time: string
consumed_ml: number | null
milk_type: string
item: string
solid_g: number | null
kcal: number | null
kind: string
note: string
}
export interface DiaperItem {
id?: string
time: string
pee: boolean
poo: boolean
texture: string
note: string
}
export interface HistoryRow {
date: string
weekday: string
formatted_date: string
is_today: boolean
total_hours: number
night_duration_str: string
nap_str: string
feed_bottles_ml: number
feed_bottles_count: number
feed_solid_g: number
feed_solid_count: number
diaper_changes: number
diaper_dirty: number
sessions: {
id?: string
start: string
end: string
type: string
duration_minutes: number | null
is_open: boolean
note: string
location?: string
}[]
}
export interface GrowthMeasurement {
id: string
date_str: string
date_pretty: string
age_str: string
age_months: number
weight_kg: number | null
weight_pct: string
height_cm: number | null
height_pct: string
head_circ_cm: number | null
note: string
}
export interface DayDetailData {
date: string
weekday: string
sleep_sessions: NapSession[]
feedings: FeedingItem[]
diapers: DiaperItem[]
growth?: GrowthMeasurement[]
}
export interface AgeNorms {
category: string
sweet_spot_wake_window_min: [number, number]
total_sleep_norm_hours: [number, number]
night_sleep_norm_hours: [number, number]
nap_count_norm: [number, number]
feed_interval_hours: [number, number]
guidelines_reference: string
}
export interface MilaDashboardData {
status: string
execution_time_ms: number
git_status: string
git_conflict: boolean
metadata: {
subject: string
today_date: string
today_pretty: string
age_str: string
}
live_status: {
is_sleeping: boolean
active_sleep_id: string | null
active_sleep_duration_str: string
active_sleep_start_time: string
active_sleep_iso: string
wake_window_minutes: number | null
wake_window_str: string
last_wake_time_str: string
last_wake_iso: string
last_feed_time: string | null
last_feed_elapsed_minutes: number | null
last_feed_item: string | null
last_feed_amount: number | null
last_diaper_time: string | null
last_diaper_elapsed_minutes: number | null
last_stool_time: string | null
last_stool_elapsed_minutes: number | null
}
age_norms?: AgeNorms
today: {
total_sleep_hours_str: string
nap_count: number
nap_minutes: number
night_duration_str: string
naps: NapSession[]
nights: NapSession[]
feedings: FeedingItem[]
feeding_summary: {
bottle_count: number
total_consumed_ml: number
solid_count: number
total_solid_g: number
total_kcal: number
}
diapers: DiaperItem[]
diaper_summary: {
total_changes: number
wet_count: number
dirty_count: number
}
}
averages: {
avg_7_tot: string
avg_7_night: string
avg_7_naps: string
avg_30_tot: string
avg_7_feeding_ml?: number
avg_7_solid_g?: number
avg_7_diapers?: number
}
history: HistoryRow[]
growth: {
measurements: GrowthMeasurement[]
latest: GrowthMeasurement | null
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/tracking/mila/',
plugins: [
vue(),
tailwindcss(),
],
server: {
port: 5173,
proxy: {
'/duckdb-api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})
+9
View File
@@ -0,0 +1,9 @@
# AGENTS.md — Moritz Tracking App
## Development
```bash
cd projects/moritz
npm install
npm run dev
npm run build
```
+13
View File
@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist/ /usr/share/nginx/html/
RUN mkdir -p /usr/share/nginx/html/tracking && ln -s /usr/share/nginx/html /usr/share/nginx/html/tracking/moritz
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
# Moritz Tracking App
Apple Health-styled personal energy balance and Garmin physiological metrics dashboard.
Built with Vue 3 (Composition API), Vite, TypeScript, and Tailwind CSS.
## Features
- **Hero Netto-Bilanz**: 24h caloric surplus/deficit calculated by DuckDB (Intake vs. Garmin Burn).
- **Today's Intake**: Calories, macros (protein, carbs, fat), and real-time meal counter.
- **Garmin Recovery & Sleep**: Sleep score, HRV, resting heart rate, body battery, sleep stages.
- **7-Day Trend**: Daily comparative bars and rolling averages.
- **30-Day History**: Monthly summary and day-by-day table.
- **Tracking Modal**: 1-tap quick favorite meals + custom detailed logging.
- **Backend**: Direct sub-30ms analytical queries to FastAPI DuckDB backend at `/duckdb-api/v1`.
+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de" class="h-full bg-slate-50 overflow-x-hidden w-full max-w-full">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<title>Moritz Health — Energiebilanz & Sport</title>
<!-- PWA & Mobile Web Capabilities -->
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="Moritz Health" />
<meta name="application-name" content="Moritz Health" />
<meta name="theme-color" content="#10b981" />
<meta name="msapplication-TileColor" content="#10b981" />
<!-- Manifest with credentials for forward-auth SSO -->
<link rel="manifest" href="/tracking/moritz/moritz.webmanifest" crossorigin="use-credentials" />
<link rel="apple-touch-icon" href="/tracking/moritz/moritz-apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/tracking/moritz/moritz-apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/tracking/moritz/moritz-icon-192.png" />
<link rel="apple-touch-icon" sizes="512x512" href="/tracking/moritz/moritz-icon-512.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/tracking/moritz/moritz-icon-192.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/tracking/moritz/moritz-icon-32.png" />
<link rel="icon" type="image/svg+xml" href="/tracking/moritz/moritz-icon.svg" />
<link rel="shortcut icon" href="/tracking/moritz/favicon.ico" />
<!-- Typography -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="min-h-full text-slate-800 antialiased selection:bg-emerald-500 selection:text-white pb-16 overflow-x-hidden w-full max-w-full">
<div id="app" class="w-full max-w-full overflow-x-hidden"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+53
View File
@@ -0,0 +1,53 @@
server {
listen 80;
server_name _;
absolute_redirect off;
port_in_redirect off;
root /usr/share/nginx/html;
index index.html;
include /etc/nginx/mime.types;
types {
application/manifest+json webmanifest;
}
location = /tracking/moritz {
return 301 /tracking/moritz/;
}
location /tracking/moritz/ {
try_files $uri $uri/ /tracking/moritz/index.html /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
# Service worker
location ~* /sw\.js$ {
default_type application/javascript;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
add_header Service-Worker-Allowed "/tracking/moritz/";
}
# Webmanifest and manifest.json
location ~* /(?:moritz\.webmanifest|manifest\.webmanifest|manifest\.json)$ {
default_type application/manifest+json;
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Static assets cache
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|svg|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public";
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
+2251
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "moritz-tracking",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"lucide-vue-next": "^0.468.0",
"vue": "^3.5.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.9",
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.0.9",
"typescript": "^5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/moritz/",
"name": "Moritz Health — Energiebilanz & Sport",
"short_name": "Health",
"description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.",
"start_url": "/tracking/moritz/",
"scope": "/tracking/moritz/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f8fafc",
"theme_color": "#10b981",
"icons": [
{
"src": "/tracking/moritz/moritz-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/moritz/moritz-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/moritz/moritz-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/moritz/moritz-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/moritz/moritz-apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/moritz/moritz-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<linearGradient id="moritz-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#10b981"/>
<stop offset="100%" stop-color="#059669"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="120" fill="url(#moritz-grad)"/>
<path d="M 95 256 H 175 L 217.5 155 L 282.5 357 L 325 256 H 417" fill="none" stroke="#FFFFFF" stroke-width="39" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 537 B

+48
View File
@@ -0,0 +1,48 @@
{
"id": "/tracking/moritz/",
"name": "Moritz Health — Energiebilanz & Sport",
"short_name": "Health",
"description": "Persönliches Dashboard für Kalorienbilanz, Ernährung und Garmin-Fitness von Moritz.",
"start_url": "/tracking/moritz/",
"scope": "/tracking/moritz/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#f8fafc",
"theme_color": "#10b981",
"icons": [
{
"src": "/tracking/moritz/moritz-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/moritz/moritz-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/moritz/moritz-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/tracking/moritz/moritz-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/tracking/moritz/moritz-apple-touch-icon.png",
"sizes": "180x180",
"type": "image/png"
},
{
"src": "/tracking/moritz/moritz-icon.svg",
"sizes": "any",
"type": "image/svg+xml"
}
]
}
+51
View File
@@ -0,0 +1,51 @@
// Minimal service worker for Moritz Health PWA with instant cache-bypassing navigation
const CACHE_NAME = 'moritz-pwa-v5';
self.addEventListener('install', (event) => {
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) return caches.delete(key);
})
)
).then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
// For HTML documents/navigations: ALWAYS bypass HTTP cache to get latest live server rendering
if (event.request.destination === 'document' || event.request.mode === 'navigate') {
event.respondWith(
fetch(event.request, { cache: 'no-cache' })
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
return;
}
// Assets (images, fonts, scripts): network-first with offline cache fallback
event.respondWith(
fetch(event.request)
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => caches.match(event.request))
);
});
+763
View File
@@ -0,0 +1,763 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import {
Activity,
RefreshCw,
PlusCircle,
AlertTriangle,
Utensils,
Flame,
ChevronRight,
ChevronDown,
ChevronUp,
Footprints,
X,
Layers,
Download,
Smartphone,
Trash2,
ExternalLink,
} from 'lucide-vue-next'
import { fetchMoritzDashboard, deleteNutritionEvent, fetchMoritzDayDetail } from './api'
import type { MoritzDashboardData, MoritzDayDetail } from './types'
import MealTrackingModal from './components/MealTrackingModal.vue'
import EnergyBalanceChart from './components/EnergyBalanceChart.vue'
const data = ref<MoritzDashboardData | null>(null)
const isLoading = ref(true)
const fetchError = ref<string | null>(null)
const isTrackingModalOpen = ref(false)
const historyFilter = ref<'7d' | '14d' | '30d'>('7d')
const historyViewMode = ref<'cards' | 'table'>('cards')
const expandedHistoryRows = ref<Set<string>>(new Set())
// Day Detail Modal state
const selectedDayDetail = ref<MoritzDayDetail | null>(null)
const isDayDetailModalOpen = ref(false)
const isLoadingDayDetail = ref(false)
// PWA Install state
const deferredInstallPrompt = ref<any>(null)
const isInstallable = ref(false)
const isStandalone = ref(false)
const isIos = ref(false)
const showIosInstallGuide = ref(false)
// Detail modal states
const activeDetailModal = ref<'today_meals' | 'yesterday_meals' | 'none'>('none')
onMounted(async () => {
if (
window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true
) {
isStandalone.value = true
}
const ua = window.navigator.userAgent.toLowerCase()
isIos.value = /iphone|ipad|ipod/.test(ua) && !/crios|fxios/.test(ua)
window.addEventListener('beforeinstallprompt', (e: Event) => {
e.preventDefault()
deferredInstallPrompt.value = e
isInstallable.value = true
})
window.addEventListener('appinstalled', () => {
isInstallable.value = false
deferredInstallPrompt.value = null
isStandalone.value = true
})
await loadDashboard()
})
async function promptInstall() {
if (deferredInstallPrompt.value) {
deferredInstallPrompt.value.prompt()
const choice = await deferredInstallPrompt.value.userChoice
if (choice && choice.outcome === 'accepted') {
isInstallable.value = false
}
deferredInstallPrompt.value = null
} else {
showIosInstallGuide.value = true
}
}
async function loadDashboard() {
isLoading.value = true
fetchError.value = null
try {
data.value = await fetchMoritzDashboard()
} catch (err: any) {
fetchError.value = err.message || 'Verbindung zum Backend fehlgeschlagen'
} finally {
isLoading.value = false
}
}
function toggleHistoryRow(date: string) {
if (expandedHistoryRows.value.has(date)) {
expandedHistoryRows.value.delete(date)
} else {
expandedHistoryRows.value.add(date)
}
}
async function openDayDetail(date: string) {
isLoadingDayDetail.value = true
isDayDetailModalOpen.value = true
try {
selectedDayDetail.value = await fetchMoritzDayDetail(date)
} catch (e: any) {
console.error('Failed to load day detail:', e)
} finally {
isLoadingDayDetail.value = false
}
}
async function handleDeleteMeal(mealId?: string) {
if (!mealId) return
if (!confirm('Mahlzeit wirklich löschen?')) return
try {
await deleteNutritionEvent(mealId)
await loadDashboard()
if (selectedDayDetail.value && isDayDetailModalOpen.value) {
selectedDayDetail.value = await fetchMoritzDayDetail(selectedDayDetail.value.date)
}
} catch (err: any) {
alert(err.message || 'Fehler beim Löschen')
}
}
const filteredHistoryDays = computed(() => {
if (!data.value?.thirty_days?.days) return []
const count = historyFilter.value === '7d' ? 7 : historyFilter.value === '14d' ? 14 : 30
return data.value.thirty_days.days.slice(0, count)
})
</script>
<template>
<div class="min-h-screen text-slate-800 flex flex-col bg-slate-50">
<!-- Git Sync Conflict Warning -->
<div v-if="data?.git_conflict" class="bg-amber-500/10 border-b border-amber-300 px-4 py-3 text-amber-900 text-xs flex items-center justify-between">
<div class="max-w-4xl mx-auto w-full flex items-center gap-2">
<AlertTriangle class="w-4 h-4 text-amber-600 shrink-0" />
<span>
<strong>Git-Synchronisationskonflikt:</strong> Lokale Änderungen werden zwischengespeichert bis zur Auflösung.
</span>
</div>
</div>
<!-- Error Banner -->
<div v-if="fetchError" class="bg-rose-500/10 border-b border-rose-300 px-4 py-2.5 text-rose-900 text-xs flex items-center justify-between">
<div class="max-w-4xl mx-auto w-full flex items-center justify-between">
<span>{{ fetchError }}</span>
<button @click="loadDashboard" class="underline font-semibold ml-2">Erneut versuchen</button>
</div>
</div>
<!-- STREAMLINED HEADER -->
<header class="border-b border-slate-200/80 bg-white/85 backdrop-blur-xl sticky top-0 z-40 px-3 py-2.5 sm:px-6 sm:py-3 transition-all">
<div class="max-w-4xl mx-auto flex items-center justify-between gap-2">
<!-- Brand & Subject -->
<div class="flex items-center gap-2.5 sm:gap-3 min-w-0">
<div class="w-8.5 h-8.5 sm:w-10 sm:h-10 rounded-xl bg-gradient-to-tr from-emerald-600 via-teal-500 to-sky-500 p-0.5 shadow-sm shadow-emerald-500/20 shrink-0 overflow-hidden flex items-center justify-center">
<Activity class="w-5 h-5 text-white" />
</div>
<div class="min-w-0">
<h1 class="text-sm sm:text-base font-heading font-extrabold tracking-tight text-slate-900 truncate">
Moritz Health & Nutrition
</h1>
<p class="text-[10px] sm:text-[11px] text-slate-500 truncate">{{ data?.metadata?.today_pretty || 'Lade...' }}</p>
</div>
</div>
<!-- Header Actions -->
<div class="flex items-center gap-1.5 sm:gap-2 shrink-0">
<!-- Install App Button (PWA) -->
<button
v-if="!isStandalone && (isInstallable || isIos)"
@click="promptInstall"
title="App zum Home-Bildschirm hinzufügen"
class="h-8.5 sm:h-9 px-2 sm:px-2.5 rounded-xl bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 text-emerald-800 flex items-center gap-1 text-[11px] sm:text-xs font-semibold transition active:scale-95 shadow-xs"
>
<Download class="w-3.5 h-3.5 shrink-0" />
<span class="hidden sm:inline">Installieren</span>
</button>
<!-- Refresh Button -->
<button
@click="loadDashboard"
:disabled="isLoading"
title="Aktualisieren"
class="h-8.5 w-8.5 sm:h-9 sm:w-9 rounded-xl flex items-center justify-center bg-white border border-slate-200 text-slate-500 hover:text-slate-900 hover:border-emerald-500 hover:bg-slate-50 transition active:scale-95 shrink-0 shadow-xs"
>
<RefreshCw :class="{ 'animate-spin': isLoading }" class="w-3.5 h-3.5 sm:w-4 sm:h-4" />
</button>
<!-- Today's Intake Pill / Trigger -->
<button
v-if="data"
@click="activeDetailModal = 'today_meals'"
class="h-8.5 sm:h-9 px-2.5 sm:px-3 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-700 hover:bg-emerald-500/20 text-[11px] sm:text-xs font-semibold flex items-center gap-1.5 shadow-xs shrink-0 transition"
>
<Utensils class="w-3.5 h-3.5" />
<span>Heute ({{ data.today.item_count }})</span>
</button>
<!-- Desktop Record Action Button -->
<button
@click="isTrackingModalOpen = true"
class="hidden sm:inline-flex items-center gap-1.5 h-8.5 sm:h-9 px-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold shadow-sm shadow-emerald-600/20 active:scale-95 transition shrink-0"
>
<PlusCircle class="w-3.5 h-3.5" />
<span>Erfassen</span>
</button>
</div>
</div>
</header>
<!-- MAIN CONTENT -->
<main v-if="data" class="max-w-4xl mx-auto px-4 sm:px-6 pt-5 sm:pt-6 space-y-6 flex-1 w-full pb-24">
<!-- 1. TOP HERO SECTION: TODAY INTAKE & YESTERDAY 24H NET BALANCE -->
<section class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- CARD A: HEUTE INTAKE & MAKROS -->
<div
@click="activeDetailModal = 'today_meals'"
class="cursor-pointer group bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition flex flex-col justify-between"
>
<div>
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-sky-500 animate-pulse"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Heute (Intake bisher)</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-sky-600 flex items-center gap-1 transition">
Mahlzeiten <ChevronRight class="w-3.5 h-3.5" />
</span>
</div>
<div class="pt-4 space-y-1.5">
<div class="flex items-baseline gap-2">
<span class="text-3xl sm:text-4xl font-extrabold font-mono tracking-tight text-slate-900">
{{ data.today.total_kcal.toLocaleString('de-DE') }}
</span>
<span class="text-lg font-sans font-medium text-slate-400">kcal</span>
</div>
<p class="text-xs text-slate-500">
{{ data.today.item_count }} Mahlzeiten erfasst · Tippen für Details
</p>
</div>
</div>
<!-- Macros Pills -->
<div class="pt-4 border-t border-slate-100 grid grid-cols-3 gap-2 text-center text-xs">
<div class="p-2 rounded-xl bg-sky-50">
<div class="font-mono font-bold text-sky-800">{{ data.today.protein_g }}g</div>
<div class="text-[10px] text-sky-600 font-medium">Protein</div>
</div>
<div class="p-2 rounded-xl bg-amber-50">
<div class="font-mono font-bold text-amber-800">{{ data.today.carbs_g }}g</div>
<div class="text-[10px] text-amber-600 font-medium">Carbs</div>
</div>
<div class="p-2 rounded-xl bg-rose-50">
<div class="font-mono font-bold text-rose-800">{{ data.today.fat_g }}g</div>
<div class="text-[10px] text-rose-600 font-medium">Fett</div>
</div>
</div>
</div>
<!-- CARD B: GESTERN 24H NETTO-BILANZ HERO -->
<div
@click="activeDetailModal = 'yesterday_meals'"
class="cursor-pointer group bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm hover:shadow-md hover:border-slate-300 transition relative overflow-hidden flex flex-col justify-between"
>
<div>
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
<span class="text-xs font-bold uppercase tracking-wider text-slate-500">Gestern (24h Netto-Bilanz)</span>
</div>
<span class="text-xs font-semibold text-slate-400 group-hover:text-emerald-600 flex items-center gap-1 transition">
Deep Dive <ChevronRight class="w-3.5 h-3.5" />
</span>
</div>
<div class="pt-4 space-y-3">
<div class="flex flex-wrap items-baseline justify-between gap-2">
<div class="text-3xl sm:text-4xl font-extrabold font-mono tracking-tight text-slate-900">
<span v-if="data.yesterday.balance_kcal !== null">
{{ data.yesterday.balance_kcal > 0 ? `+${data.yesterday.balance_kcal}` : data.yesterday.balance_kcal }} kcal
</span>
<span v-else class="text-slate-400 text-2xl font-normal">Garmin ausstehend</span>
</div>
<span
:class="data.yesterday.balance_status === 'Defizit' ? 'text-emerald-700 bg-emerald-50 border-emerald-200' : 'text-amber-700 bg-amber-50 border-amber-200'"
class="px-3 py-1 rounded-full text-xs font-bold border"
>
{{ data.yesterday.balance_status }}
</span>
</div>
<!-- Formula Breakdown -->
<div class="p-3 rounded-2xl bg-slate-50 border border-slate-100 flex flex-wrap items-center justify-between gap-2 text-xs sm:text-sm font-mono">
<div class="flex items-center gap-1 text-sky-700">
<Utensils class="w-3.5 h-3.5 text-sky-500" />
<span class="font-bold">{{ data.yesterday.intake_kcal.toLocaleString('de-DE') }}</span>
</div>
<span class="text-slate-400 font-bold">−</span>
<div class="flex items-center gap-1 text-amber-700">
<Flame class="w-3.5 h-3.5 text-amber-500" />
<span class="font-bold">{{ data.yesterday.expenditure_kcal.toLocaleString('de-DE') }}</span>
</div>
<span class="text-slate-400 font-bold">=</span>
<div class="font-bold text-slate-900">
{{ data.yesterday.balance_kcal !== null ? `${data.yesterday.balance_kcal > 0 ? '+' : ''}${data.yesterday.balance_kcal}` : '—' }} kcal
</div>
</div>
</div>
</div>
<!-- Macros & Steps row -->
<div class="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-slate-100 text-xs text-slate-500">
<div class="flex items-center gap-2">
<span><strong>{{ data.yesterday.protein_g }}g</strong> P</span>
<span>·</span>
<span><strong>{{ data.yesterday.carbs_g }}g</strong> C</span>
<span>·</span>
<span><strong>{{ data.yesterday.fat_g }}g</strong> F</span>
</div>
<div v-if="data.yesterday.steps" class="flex items-center gap-1 font-mono text-emerald-700 font-semibold">
<Footprints class="w-3.5 h-3.5" />
<span>{{ data.yesterday.steps.toLocaleString('de-DE') }} Schritte</span>
</div>
</div>
</div>
</section>
<!-- 2. GARMIN SLEEP & RECOVERY -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2 pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="text-lg">⌚</span>
<div>
<h2 class="text-sm font-bold text-slate-900">Garmin Schlaf & Erholung</h2>
<p class="text-[11px] text-slate-500">{{ data.metadata.sleep_date_label }}</p>
</div>
</div>
<span
v-if="data.garmin_recovery.sleep_score"
class="px-2.5 py-1 rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200 text-xs font-bold font-mono"
>
Score {{ data.garmin_recovery.sleep_score }} ({{ data.garmin_recovery.sleep_qualifier }})
</span>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100">
<div class="text-[11px] text-slate-500 font-semibold">Schlafdauer</div>
<div class="text-xl font-bold font-mono text-slate-900 mt-1">{{ data.garmin_recovery.sleep_dur_str }}</div>
<div class="text-[11px] text-slate-400 mt-0.5">Gesamtschlaf</div>
</div>
<div class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100">
<div class="text-[11px] text-slate-500 font-semibold">HRV Status</div>
<div class="text-xl font-bold font-mono text-slate-900 mt-1">
{{ data.garmin_recovery.hrv_val ? `${data.garmin_recovery.hrv_val} ms` : '—' }}
</div>
<div class="text-[11px] text-emerald-600 font-medium mt-0.5">{{ data.garmin_recovery.hrv_status || 'Ausgeglichen' }}</div>
</div>
<div class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100">
<div class="text-[11px] text-slate-500 font-semibold">Ruhepuls</div>
<div class="text-xl font-bold font-mono text-slate-900 mt-1">
{{ data.garmin_recovery.resting_hr ? `${data.garmin_recovery.resting_hr} bpm` : '—' }}
</div>
<div class="text-[11px] text-slate-400 mt-0.5">Nacht-Minimum</div>
</div>
<div class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100">
<div class="text-[11px] text-slate-500 font-semibold">Body Battery</div>
<div class="text-xl font-bold font-mono text-emerald-700 mt-1">
{{ data.garmin_recovery.bb_change !== null ? `+${data.garmin_recovery.bb_change}` : '—' }}
</div>
<div class="text-[11px] text-slate-400 mt-0.5">Übernacht-Aufladung</div>
</div>
</div>
</section>
<!-- 3. INTERACTIVE ENERGY BALANCE CHART -->
<EnergyBalanceChart :days="data.thirty_days.days" />
<!-- 4. 30-DAY METABOLIC HISTORY -->
<section class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<span class="text-lg">📅</span>
<div>
<h2 class="text-sm font-bold text-slate-900">Metabolische Historie</h2>
<p class="text-[11px] text-slate-500">Netto-Energiebilanzen & Makro-Historie</p>
</div>
</div>
<!-- Period Filter & View Mode Toggle -->
<div class="flex items-center gap-2 self-start sm:self-auto">
<div class="inline-flex rounded-xl bg-slate-100 p-1 text-xs font-semibold">
<button
v-for="p in (['7d', '14d', '30d'] as const)"
:key="p"
@click="historyFilter = p"
:class="historyFilter === p ? 'bg-emerald-600 text-white' : 'text-slate-500 hover:text-slate-900'"
class="px-2.5 py-1 rounded-lg transition"
>
{{ p === '7d' ? '7 Tage' : p === '14d' ? '14 Tage' : '30 Tage' }}
</button>
</div>
<button
@click="historyViewMode = historyViewMode === 'cards' ? 'table' : 'cards'"
class="p-1.5 rounded-xl bg-slate-100 border border-slate-200/80 text-slate-600 hover:text-slate-900 transition"
:title="historyViewMode === 'cards' ? 'Tabellenansicht' : 'Kartenansicht'"
>
<Layers class="w-4 h-4" />
</button>
</div>
</div>
<!-- Cards View -->
<div v-if="historyViewMode === 'cards'" class="space-y-2.5">
<div
v-for="row in filteredHistoryDays"
:key="row.date"
class="rounded-2xl bg-slate-50/80 border border-slate-200/80 overflow-hidden transition hover:border-slate-300"
>
<div
@click="toggleHistoryRow(row.date)"
class="p-3.5 flex items-center justify-between cursor-pointer select-none"
>
<div class="flex items-center gap-2 min-w-0">
<ChevronUp v-if="expandedHistoryRows.has(row.date)" class="w-4 h-4 text-emerald-600 shrink-0" />
<ChevronDown v-else class="w-4 h-4 text-slate-400 shrink-0" />
<div class="truncate">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-slate-900 text-xs sm:text-sm">
{{ row.weekday }}, {{ row.date.slice(5) }}
</span>
<span
:class="row.balance_status === 'Defizit' ? 'text-emerald-700 bg-emerald-100/70 border-emerald-300' : (row.balance_status === 'Überschuss' ? 'text-amber-700 bg-amber-100/70 border-amber-300' : 'text-slate-500 bg-slate-200/60 border-slate-300')"
class="px-2 py-0.2 rounded-full text-[10px] font-bold border"
>
{{ row.balance_status }}
</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-slate-500 mt-0.5 font-mono">
<span>Intake: {{ row.intake_kcal }} kcal</span>
<span>·</span>
<span>Verbrauch: {{ row.expenditure_kcal }} kcal</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<div class="text-right shrink-0">
<div class="text-sm sm:text-base font-extrabold font-mono" :class="row.balance_kcal && row.balance_kcal < 0 ? 'text-emerald-700' : 'text-amber-700'">
{{ row.balance_kcal !== null ? `${row.balance_kcal > 0 ? '+' : ''}${row.balance_kcal} kcal` : '—' }}
</div>
<div class="text-[10px] text-slate-400 font-mono">
{{ row.steps ? `${row.steps.toLocaleString('de-DE')} Schritte` : '' }}
</div>
</div>
<button
@click.stop="openDayDetail(row.date)"
title="Ganztags-Mahlzeiten öffnen"
class="p-1.5 rounded-lg text-slate-400 hover:text-emerald-700 hover:bg-slate-200 transition"
>
<ExternalLink class="w-4 h-4" />
</button>
</div>
</div>
<!-- Inline Expanded Details -->
<div
v-if="expandedHistoryRows.has(row.date)"
class="p-3.5 pt-0 border-t border-slate-200/60 bg-white space-y-2 text-xs"
>
<div class="pt-2 grid grid-cols-2 sm:grid-cols-4 gap-2 font-mono text-[11px]">
<div class="p-2 rounded-xl bg-slate-50">
<div class="text-slate-400 text-[10px]">Protein</div>
<div class="font-bold text-sky-800">{{ row.protein_g }}g</div>
</div>
<div class="p-2 rounded-xl bg-slate-50">
<div class="text-slate-400 text-[10px]">Kohlenhydrate</div>
<div class="font-bold text-amber-800">{{ row.carbs_g }}g</div>
</div>
<div class="p-2 rounded-xl bg-slate-50">
<div class="text-slate-400 text-[10px]">Fett</div>
<div class="font-bold text-rose-800">{{ row.fat_g }}g</div>
</div>
<div class="p-2 rounded-xl bg-slate-50">
<div class="text-slate-400 text-[10px]">Ruhepuls</div>
<div class="font-bold text-slate-800">{{ row.resting_hr ? `${row.resting_hr} bpm` : '—' }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Table View -->
<div v-else class="overflow-x-auto">
<table class="w-full text-left text-xs">
<thead class="border-b border-slate-100 text-slate-400 uppercase font-mono text-[10px]">
<tr>
<th class="py-2.5 px-3">Datum</th>
<th class="py-2.5 px-3">Intake</th>
<th class="py-2.5 px-3">Verbrauch</th>
<th class="py-2.5 px-3">Netto-Bilanz</th>
<th class="py-2.5 px-3">Status</th>
<th class="py-2.5 px-3">Schritte</th>
<th class="py-2.5 px-3">Detail</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr
v-for="row in filteredHistoryDays"
:key="row.date"
class="hover:bg-slate-50 transition"
>
<td class="py-3 px-3 font-mono font-medium text-slate-800">
{{ row.weekday }}, {{ row.date.slice(5) }}
</td>
<td class="py-3 px-3 font-mono font-semibold text-slate-800">
{{ row.intake_kcal ? `${row.intake_kcal.toLocaleString('de-DE')} kcal` : '—' }}
</td>
<td class="py-3 px-3 font-mono font-semibold text-slate-500">
{{ row.expenditure_kcal ? `${row.expenditure_kcal.toLocaleString('de-DE')} kcal` : '—' }}
</td>
<td class="py-3 px-3 font-mono font-bold" :class="row.balance_kcal && row.balance_kcal < 0 ? 'text-emerald-700' : 'text-amber-700'">
{{ row.balance_kcal !== null ? `${row.balance_kcal > 0 ? '+' : ''}${row.balance_kcal} kcal` : '—' }}
</td>
<td class="py-3 px-3 font-semibold">
<span
:class="row.balance_status === 'Defizit' ? 'text-emerald-700 bg-emerald-50 border-emerald-200' : (row.balance_status === 'Überschuss' ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-slate-500 bg-slate-100 border-slate-200')"
class="px-2 py-0.5 rounded-full text-[10px] border"
>
{{ row.balance_status }}
</span>
</td>
<td class="py-3 px-3 font-mono text-slate-600">
{{ row.steps ? row.steps.toLocaleString('de-DE') : '—' }}
</td>
<td class="py-3 px-3">
<button
@click="openDayDetail(row.date)"
title="Mahlzeiten öffnen"
class="p-1 rounded text-slate-400 hover:text-emerald-700 hover:bg-slate-100 transition"
>
<ExternalLink class="w-3.5 h-3.5" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</main>
<!-- LOADING STATE -->
<div v-else-if="isLoading" class="flex-1 flex flex-col items-center justify-center p-12 space-y-3">
<RefreshCw class="w-8 h-8 animate-spin text-emerald-600" />
<p class="text-xs font-mono text-slate-500">Lade Moritz Dashboard aus DuckDB...</p>
</div>
<!-- MOBILE FAB -->
<button
@click="isTrackingModalOpen = true"
class="sm:hidden fixed bottom-6 right-5 z-40 flex items-center gap-2 px-4 py-3.5 rounded-full bg-gradient-to-r from-emerald-600 to-teal-600 text-white font-bold text-sm shadow-xl shadow-emerald-600/40 active:scale-95 transition"
>
<PlusCircle class="w-5 h-5" />
<span>Erfassen</span>
</button>
<!-- MEALS DEEP DIVE MODAL (Today / Yesterday) -->
<div v-if="activeDetailModal !== 'none'" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-900/60 backdrop-blur-sm">
<div class="w-full sm:max-w-xl bg-white rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<div class="flex items-center gap-2">
<Utensils class="w-4 h-4 text-emerald-600" />
<h3 class="text-base font-bold text-slate-900">
{{ activeDetailModal === 'today_meals' ? 'Heutige Mahlzeiten' : 'Gestrige Mahlzeiten' }}
</h3>
</div>
<button @click="activeDetailModal = 'none'" class="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition">
<X class="w-5 h-5" />
</button>
</div>
<div class="p-6 overflow-y-auto space-y-3 flex-1">
<template v-if="data">
<div
v-for="(meal, idx) in (activeDetailModal === 'today_meals' ? data.today.meals : data.yesterday.meals)"
:key="idx"
class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between"
>
<div>
<div class="text-xs font-bold text-slate-900">{{ meal.item }}</div>
<div class="text-[11px] text-slate-400 font-mono mt-0.5">
{{ meal.time }} Uhr · {{ meal.qty ? `${meal.qty} ${meal.unit}` : '' }}
</div>
<div class="text-[10px] text-slate-500 font-mono mt-0.5 space-x-2">
<span>{{ meal.protein_g }}g P</span>
<span>·</span>
<span>{{ meal.carbs_g }}g C</span>
<span>·</span>
<span>{{ meal.fat_g }}g F</span>
</div>
</div>
<div class="text-right flex items-center gap-2">
<span class="text-sm font-bold font-mono text-emerald-700">{{ meal.kcal }} kcal</span>
<button
v-if="meal.id"
@click="handleDeleteMeal(meal.id)"
title="Mahlzeit löschen"
class="p-1 text-slate-400 hover:text-rose-600 rounded-lg hover:bg-slate-200 transition"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div
v-if="(activeDetailModal === 'today_meals' ? data.today.meals : data.yesterday.meals).length === 0"
class="p-6 text-center text-xs text-slate-400"
>
Keine Mahlzeiten für diesen Tag verzeichnet.
</div>
</template>
</div>
</div>
</div>
<!-- HISTORICAL DAY DETAIL MODAL -->
<div
v-if="isDayDetailModalOpen"
class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-900/60 backdrop-blur-sm"
@click.self="isDayDetailModalOpen = false"
>
<div class="w-full sm:max-w-xl bg-white rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[85vh]">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<div>
<h3 class="text-base font-bold text-slate-900">
Mahlzeiten: {{ selectedDayDetail ? `${selectedDayDetail.weekday}, ${selectedDayDetail.date}` : 'Lade...' }}
</h3>
<p class="text-xs text-slate-500">Mahlzeiten und Nährwerte dieses Tages</p>
</div>
<button @click="isDayDetailModalOpen = false" class="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition">
<X class="w-5 h-5" />
</button>
</div>
<div v-if="isLoadingDayDetail" class="p-12 flex flex-col items-center justify-center space-y-2">
<RefreshCw class="w-6 h-6 animate-spin text-emerald-600" />
<span class="text-xs text-slate-400 font-mono">Lade Mahlzeiten...</span>
</div>
<div v-else-if="selectedDayDetail" class="p-6 overflow-y-auto space-y-4 flex-1">
<!-- Summary card -->
<div class="p-3.5 rounded-2xl bg-emerald-50/50 border border-emerald-100 flex items-center justify-between text-xs">
<div>
<span class="text-slate-500 font-mono">Tages-Intake:</span>
<strong class="text-emerald-800 ml-1.5 font-mono text-sm">{{ selectedDayDetail.nutrition.total_kcal }} kcal</strong>
</div>
<div class="font-mono text-[11px] text-slate-600 space-x-2">
<span>{{ selectedDayDetail.nutrition.protein_g }}g P</span>
<span>·</span>
<span>{{ selectedDayDetail.nutrition.carbs_g }}g C</span>
<span>·</span>
<span>{{ selectedDayDetail.nutrition.fat_g }}g F</span>
</div>
</div>
<!-- Meals List -->
<div class="space-y-2">
<div
v-for="(meal, idx) in selectedDayDetail.nutrition.meals"
:key="idx"
class="p-3.5 rounded-2xl bg-slate-50 border border-slate-100 flex items-center justify-between text-xs"
>
<div>
<div class="font-bold text-slate-900">{{ meal.item }}</div>
<div class="text-[11px] text-slate-400 font-mono mt-0.5">
{{ meal.time }} Uhr · {{ meal.qty ? `${meal.qty} ${meal.unit}` : '' }}
</div>
<div class="text-[10px] text-slate-500 font-mono mt-0.5 space-x-2">
<span>{{ meal.protein_g }}g P</span>
<span>·</span>
<span>{{ meal.carbs_g }}g C</span>
<span>·</span>
<span>{{ meal.fat_g }}g F</span>
</div>
</div>
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-emerald-700 text-sm">{{ meal.kcal }} kcal</span>
<button
v-if="meal.id"
@click="handleDeleteMeal(meal.id)"
title="Mahlzeit löschen"
class="p-1 text-slate-400 hover:text-rose-600 rounded-lg hover:bg-slate-200 transition"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
</div>
<div v-if="selectedDayDetail.nutrition.meals.length === 0" class="text-slate-400 italic text-center py-4">
Keine Mahlzeiten erfasst.
</div>
</div>
</div>
</div>
</div>
<!-- MEAL TRACKING MODAL -->
<MealTrackingModal
:isOpen="isTrackingModalOpen"
@close="isTrackingModalOpen = false"
@logged="loadDashboard"
/>
<!-- iOS Add to Home Screen Instructions Modal -->
<div
v-if="showIosInstallGuide"
class="fixed inset-0 z-50 bg-slate-900/70 backdrop-blur-sm flex items-end sm:items-center justify-center p-4 transition-all"
@click.self="showIosInstallGuide = false"
>
<div class="bg-white rounded-3xl p-6 max-w-sm w-full space-y-4 shadow-2xl text-left border border-slate-200">
<div class="flex items-center justify-between pb-3 border-b border-slate-100">
<div class="flex items-center gap-2">
<Smartphone class="w-5 h-5 text-emerald-600" />
<h3 class="font-bold text-sm text-slate-900">Moritz Health auf Home-Bildschirm</h3>
</div>
<button @click="showIosInstallGuide = false" class="text-slate-400 hover:text-slate-700 text-sm font-bold p-1">✕</button>
</div>
<p class="text-xs text-slate-600 leading-relaxed">
So installierst du die Health-App auf deinem iPhone oder iPad:
</p>
<ol class="text-xs text-slate-700 space-y-2.5 list-decimal list-inside bg-slate-50 p-3.5 rounded-2xl border border-slate-100">
<li>Tippe in Safari unten auf das <strong>Teilen-Symbol</strong> (Quadrat mit Pfeil nach oben).</li>
<li>Scrolle im Menü nach unten und wähle <strong>„Zum Home-Bildschirm“</strong> (+).</li>
<li>Tippe oben rechts auf <strong>„Hinzufügen“</strong>.</li>
</ol>
<button
@click="showIosInstallGuide = false"
class="w-full py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition shadow-sm"
>
Verstanden
</button>
</div>
</div>
</div>
</template>
+96
View File
@@ -0,0 +1,96 @@
import type { MoritzDashboardData, MoritzDayDetail, FoodEntity } from './types'
const API_BASE = '/duckdb-api/v1'
export async function fetchMoritzDashboard(asOf?: string): Promise<MoritzDashboardData> {
const url = asOf ? `${API_BASE}/moritz/dashboard?as_of=${encodeURIComponent(asOf)}` : `${API_BASE}/moritz/dashboard`
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to fetch dashboard: ${res.status} ${res.statusText}`)
}
return res.json()
}
export async function fetchMoritzDayDetail(date: string): Promise<MoritzDayDetail> {
const res = await fetch(`${API_BASE}/moritz/day/${encodeURIComponent(date)}`)
if (!res.ok) {
throw new Error(`Failed to fetch day detail for ${date}: ${res.statusText}`)
}
return res.json()
}
export async function fetchFoods(): Promise<FoodEntity[]> {
try {
const res = await fetch(`${API_BASE}/entities/foods`)
if (!res.ok) return []
const json = await res.json()
return json.items || []
} catch {
return []
}
}
export function getLocalDateString(): string {
const d = new Date()
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export async function logNutritionIntake(payload: {
item: string
qty?: number
unit?: string
kcal?: number
protein_g?: number
carbs_g?: number
fat_g?: number
meal?: string
time?: string
date?: string
note?: string
}): Promise<any> {
const baseDate = payload.date || getLocalDateString()
const startIso = payload.time ? `${baseDate}T${payload.time}:00` : undefined
const res = await fetch(`${API_BASE}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
kind: 'nutrition.intake',
subject: 'moritz',
payload: {
item: payload.item,
qty: payload.qty,
unit: payload.unit || 'g',
kcal: payload.kcal,
protein_g: payload.protein_g,
carbs_g: payload.carbs_g,
fat_g: payload.fat_g,
},
meal: payload.meal || 'snack',
date: baseDate,
start: startIso,
note: payload.note,
}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to log nutrition intake')
}
return res.json()
}
export async function deleteNutritionEvent(eventId: string): Promise<any> {
const res = await fetch(`${API_BASE}/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Failed to delete event')
}
return res.json()
}
@@ -0,0 +1,563 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { HistoryDay } from '../types'
import { Flame, Utensils } from 'lucide-vue-next'
const props = defineProps<{
days: HistoryDay[]
}>()
const activePeriod = ref<'7d' | '14d' | '30d'>('7d')
const activeMode = ref<'balance' | 'macros' | 'fitness'>('balance')
const activeIndex = ref<number | null>(null)
// Days sliced for selected period (backend gives newest first or oldest first; sort chronologically left-to-right)
const periodDays = computed(() => {
const count = activePeriod.value === '7d' ? 7 : activePeriod.value === '14d' ? 14 : 30
// Slicing from latest days
const slice = props.days.slice(0, count)
// Ensure left-to-right chronological order
return [...slice].sort((a, b) => a.date.localeCompare(b.date))
})
const chartWidth = 600
const chartHeight = 220
const paddingLeft = 40
const paddingRight = 16
const paddingTop = 20
const paddingBottom = 30
const innerWidth = chartWidth - paddingLeft - paddingRight
const innerHeight = chartHeight - paddingTop - paddingBottom
// Y Max scales
const maxCalorieScale = computed(() => {
let m = 2500
for (const d of periodDays.value) {
m = Math.max(m, d.intake_kcal || 0, d.expenditure_kcal || 0)
}
return Math.ceil(m / 500) * 500
})
const maxMacroScale = computed(() => {
let m = 180
for (const d of periodDays.value) {
const totalG = (d.protein_g || 0) + (d.carbs_g || 0) + (d.fat_g || 0)
m = Math.max(m, totalG)
}
return Math.ceil(m / 50) * 50
})
const maxStepsScale = computed(() => {
let m = 12000
for (const d of periodDays.value) {
m = Math.max(m, d.steps || 0)
}
return Math.ceil(m / 2000) * 2000
})
function getYCal(val: number): number {
const clamped = Math.max(0, Math.min(maxCalorieScale.value, val))
return paddingTop + innerHeight - (clamped / maxCalorieScale.value) * innerHeight
}
function getYMacro(val: number): number {
const clamped = Math.max(0, Math.min(maxMacroScale.value, val))
return paddingTop + innerHeight - (clamped / maxMacroScale.value) * innerHeight
}
function getYSteps(val: number): number {
const clamped = Math.max(0, Math.min(maxStepsScale.value, val))
return paddingTop + innerHeight - (clamped / maxStepsScale.value) * innerHeight
}
function getYHR(bpm: number): number {
// BPM scale between 40 and 80
const clamped = Math.max(40, Math.min(80, bpm))
return paddingTop + innerHeight - ((clamped - 40) / 40) * innerHeight
}
// Bars / data points
const chartItems = computed(() => {
const days = periodDays.value
if (!days.length) return []
const slotWidth = innerWidth / days.length
const groupWidth = Math.max(10, Math.min(32, slotWidth * 0.75))
return days.map((d, idx) => {
const xCenter = paddingLeft + (idx + 0.5) * slotWidth
const x = xCenter - groupWidth / 2
// Calorie balance
const yIntake = getYCal(d.intake_kcal || 0)
const intakeHeight = Math.max(0, (paddingTop + innerHeight) - yIntake)
const yExp = getYCal(d.expenditure_kcal || 0)
const expHeight = Math.max(0, (paddingTop + innerHeight) - yExp)
// Macros
const pG = d.protein_g || 0
const cG = d.carbs_g || 0
const fG = d.fat_g || 0
const totG = pG + cG + fG
const yMacroTot = getYMacro(totG)
const yMacroCarb = getYMacro(pG + cG)
const yMacroProt = getYMacro(pG)
// Fitness
const ySteps = getYSteps(d.steps || 0)
const stepsHeight = Math.max(0, (paddingTop + innerHeight) - ySteps)
const yHR = d.resting_hr ? getYHR(d.resting_hr) : null
return {
day: d,
idx,
x,
xCenter,
groupWidth,
yIntake,
intakeHeight,
yExp,
expHeight,
pG,
cG,
fG,
totG,
yMacroTot,
yMacroCarb,
yMacroProt,
ySteps,
stepsHeight,
yHR,
}
})
})
const activeItem = computed(() => {
if (activeIndex.value === null) {
return chartItems.value.length ? chartItems.value[chartItems.value.length - 1] : null
}
return chartItems.value[activeIndex.value] || null
})
// Resting HR curve path
const hrCurvePath = computed(() => {
const pts: string[] = []
for (const item of chartItems.value) {
if (item.yHR !== null) {
pts.push(`${item.xCenter.toFixed(1)},${item.yHR.toFixed(1)}`)
}
}
return pts.length ? `M ${pts.join(' L ')}` : ''
})
// Science Period Summary Calculations
const summary = computed(() => {
const days = periodDays.value
if (!days.length) return { avgIntake: 0, avgExp: 0, totalNet: 0, avgSteps: 0, fatKg: 0 }
let sumIntake = 0
let sumExp = 0
let validBalSum = 0
let sumSteps = 0
let countIntake = 0
let countExp = 0
for (const d of days) {
if (d.intake_kcal > 0) {
sumIntake += d.intake_kcal
countIntake++
}
if (d.expenditure_kcal > 0) {
sumExp += d.expenditure_kcal
countExp++
}
if (d.balance_kcal !== null) {
validBalSum += d.balance_kcal
}
if (d.steps > 0) {
sumSteps += d.steps
}
}
const avgIntake = countIntake ? Math.round(sumIntake / countIntake) : 0
const avgExp = countExp ? Math.round(sumExp / countExp) : 0
const avgSteps = Math.round(sumSteps / days.length)
// 1 kg body fat = ~7,000 kcal
const fatKg = parseFloat((validBalSum / 7000).toFixed(2))
return {
avgIntake,
avgExp,
totalNet: validBalSum,
avgSteps,
fatKg,
}
})
function handlePointer(event: MouseEvent | TouchEvent) {
const svg = (event.currentTarget as HTMLElement).getBoundingClientRect()
const clientX = 'touches' in event ? event.touches[0].clientX : event.clientX
const relativeX = clientX - svg.left
const ratio = relativeX / svg.width
const svgX = ratio * chartWidth
let closestIdx = 0
let closestDist = Infinity
chartItems.value.forEach((item, idx) => {
const dist = Math.abs(item.xCenter - svgX)
if (dist < closestDist) {
closestDist = dist
closestIdx = idx
}
})
activeIndex.value = closestIdx
}
</script>
<template>
<div class="bg-white rounded-3xl p-5 sm:p-6 border border-slate-200/90 shadow-sm space-y-4">
<!-- Header with Period & Metric Toggles -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-slate-100">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center font-bold">
⚡
</div>
<div>
<h3 class="text-sm sm:text-base font-bold text-slate-900">Metabolische Energiebilanz & Trends</h3>
<p class="text-xs text-slate-500">Täglicher Vergleich Intake vs. Garmin-TDEE</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto justify-between sm:justify-end">
<!-- View Mode: Balance / Macros / Fitness -->
<div class="inline-flex rounded-xl bg-slate-100 p-1 border border-slate-200/80 text-xs font-semibold">
<button
@click="activeMode = 'balance'"
:class="activeMode === 'balance' ? 'bg-white text-slate-900 shadow-xs' : 'text-slate-500 hover:text-slate-900'"
class="px-2.5 py-1 rounded-lg transition"
>
Bilanz
</button>
<button
@click="activeMode = 'macros'"
:class="activeMode === 'macros' ? 'bg-white text-slate-900 shadow-xs' : 'text-slate-500 hover:text-slate-900'"
class="px-2.5 py-1 rounded-lg transition"
>
Makros
</button>
<button
@click="activeMode = 'fitness'"
:class="activeMode === 'fitness' ? 'bg-white text-slate-900 shadow-xs' : 'text-slate-500 hover:text-slate-900'"
class="px-2.5 py-1 rounded-lg transition"
>
Garmin
</button>
</div>
<!-- Period Toggle -->
<div class="inline-flex rounded-xl bg-slate-100 p-1 border border-slate-200/80 text-xs font-semibold">
<button
v-for="p in (['7d', '14d', '30d'] as const)"
:key="p"
@click="activePeriod = p; activeIndex = null"
:class="activePeriod === p ? 'bg-emerald-600 text-white shadow-xs' : 'text-slate-500 hover:text-slate-900'"
class="px-2.5 py-1 rounded-lg transition"
>
{{ p === '7d' ? '7 Tage' : p === '14d' ? '14 Tage' : '30 Tage' }}
</button>
</div>
</div>
</div>
<!-- Active Scrubber Inspector Card -->
<div v-if="activeItem" class="p-3.5 rounded-2xl bg-slate-50 border border-slate-200/70 flex flex-wrap items-center justify-between gap-3 text-xs">
<div class="flex items-center gap-2">
<span class="font-mono font-bold text-slate-800 text-sm">
{{ activeItem.day.weekday }}, {{ activeItem.day.date.slice(5) }}
</span>
<span
:class="activeItem.day.balance_status === 'Defizit' ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-amber-100 text-amber-800 border-amber-300'"
class="px-2 py-0.5 rounded-full border text-[11px] font-bold"
>
{{ activeItem.day.balance_status }}
</span>
</div>
<!-- Detail values in inspector -->
<div class="flex flex-wrap items-center gap-2 sm:gap-4 font-mono">
<div class="flex items-center gap-1 text-sky-700">
<Utensils class="w-3.5 h-3.5" />
<span>{{ activeItem.day.intake_kcal }} kcal</span>
</div>
<div class="flex items-center gap-1 text-amber-700">
<Flame class="w-3.5 h-3.5" />
<span>{{ activeItem.day.expenditure_kcal }} kcal</span>
</div>
<div class="font-bold text-slate-900">
Netto:
<span :class="activeItem.day.balance_kcal && activeItem.day.balance_kcal < 0 ? 'text-emerald-700' : 'text-amber-700'">
{{ activeItem.day.balance_kcal !== null ? `${activeItem.day.balance_kcal > 0 ? '+' : ''}${activeItem.day.balance_kcal}` : '—' }} kcal
</span>
</div>
<div class="text-slate-500 hidden sm:inline">
{{ activeItem.day.steps ? `${activeItem.day.steps.toLocaleString('de-DE')} Schritte` : '' }}
</div>
</div>
</div>
<!-- Interactive SVG Chart Area -->
<div
class="relative w-full cursor-pointer select-none touch-pan-x overflow-hidden"
@mousemove="handlePointer"
@touchmove="handlePointer"
@touchstart="handlePointer"
>
<svg
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
class="w-full h-auto overflow-hidden"
>
<!-- Horizontal Grid Lines -->
<g stroke="#f1f5f9" stroke-width="1" stroke-dasharray="2 3">
<template v-if="activeMode === 'balance'">
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYCal(maxCalorieScale)" :y2="getYCal(maxCalorieScale)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYCal(maxCalorieScale * 0.75)" :y2="getYCal(maxCalorieScale * 0.75)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYCal(maxCalorieScale * 0.5)" :y2="getYCal(maxCalorieScale * 0.5)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYCal(maxCalorieScale * 0.25)" :y2="getYCal(maxCalorieScale * 0.25)" />
</template>
<template v-else-if="activeMode === 'macros'">
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYMacro(maxMacroScale)" :y2="getYMacro(maxMacroScale)" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYMacro(maxMacroScale * 0.5)" :y2="getYMacro(maxMacroScale * 0.5)" />
</template>
<template v-else>
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYSteps(10000)" :y2="getYSteps(10000)" stroke="#10b981" stroke-opacity="0.3" />
<line :x1="paddingLeft" :x2="chartWidth - paddingRight" :y1="getYSteps(5000)" :y2="getYSteps(5000)" />
</template>
</g>
<!-- Y-Axis Labels -->
<g class="fill-slate-400 text-[10px] font-mono" text-anchor="end">
<template v-if="activeMode === 'balance'">
<text :x="paddingLeft - 6" :y="getYCal(maxCalorieScale) + 3">{{ maxCalorieScale }}</text>
<text :x="paddingLeft - 6" :y="getYCal(maxCalorieScale * 0.5) + 3">{{ maxCalorieScale * 0.5 }}</text>
<text :x="paddingLeft - 6" :y="getYCal(0) - 2">0</text>
</template>
<template v-else-if="activeMode === 'macros'">
<text :x="paddingLeft - 6" :y="getYMacro(maxMacroScale) + 3">{{ maxMacroScale }}g</text>
<text :x="paddingLeft - 6" :y="getYMacro(maxMacroScale * 0.5) + 3">{{ maxMacroScale * 0.5 }}g</text>
<text :x="paddingLeft - 6" :y="getYMacro(0) - 2">0g</text>
</template>
<template v-else>
<text :x="paddingLeft - 6" :y="getYSteps(10000) + 3">10k</text>
<text :x="paddingLeft - 6" :y="getYSteps(5000) + 3">5k</text>
<text :x="paddingLeft - 6" :y="getYSteps(0) - 2">0</text>
</template>
</g>
<!-- A. MODE: BALANCE BARS (Side-by-side or paired) -->
<g v-if="activeMode === 'balance'">
<g v-for="item in chartItems" :key="item.day.date">
<!-- Active column highlight -->
<rect
v-if="activeItem?.day.date === item.day.date"
:x="item.x - 4"
:y="paddingTop"
:width="item.groupWidth + 8"
:height="innerHeight"
fill="#0f172a"
fill-opacity="0.04"
rx="6"
/>
<!-- Intake Bar (Sky) -->
<rect
:x="item.x"
:y="item.yIntake"
:width="item.groupWidth * 0.46"
:height="item.intakeHeight"
class="fill-sky-500 hover:fill-sky-400 transition"
rx="2.5"
/>
<!-- Expenditure Bar (Amber) -->
<rect
:x="item.x + item.groupWidth * 0.54"
:y="item.yExp"
:width="item.groupWidth * 0.46"
:height="item.expHeight"
class="fill-amber-500 hover:fill-amber-400 transition"
rx="2.5"
/>
<!-- X Labels -->
<text
:x="item.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
:class="activeItem?.day.date === item.day.date ? 'fill-slate-900 font-bold' : 'fill-slate-500'"
class="text-[9px] font-mono"
>
{{ item.day.weekday }}
</text>
</g>
</g>
<!-- B. MODE: MACROS STACKED BARS -->
<g v-else-if="activeMode === 'macros'">
<g v-for="item in chartItems" :key="item.day.date">
<rect
v-if="activeItem?.day.date === item.day.date"
:x="item.x - 4"
:y="paddingTop"
:width="item.groupWidth + 8"
:height="innerHeight"
fill="#0f172a"
fill-opacity="0.04"
rx="6"
/>
<!-- Protein bottom (Sky) -->
<rect
:x="item.x"
:y="item.yMacroProt"
:width="item.groupWidth"
:height="(paddingTop + innerHeight) - item.yMacroProt"
class="fill-sky-500"
rx="2"
/>
<!-- Carbs middle (Amber) -->
<rect
v-if="item.cG > 0"
:x="item.x"
:y="item.yMacroCarb"
:width="item.groupWidth"
:height="item.yMacroProt - item.yMacroCarb"
class="fill-amber-400"
rx="2"
/>
<!-- Fat top (Rose) -->
<rect
v-if="item.fG > 0"
:x="item.x"
:y="item.yMacroTot"
:width="item.groupWidth"
:height="item.yMacroCarb - item.yMacroTot"
class="fill-rose-500"
rx="2"
/>
<text
:x="item.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
class="fill-slate-500 text-[9px] font-mono"
>
{{ item.day.weekday }}
</text>
</g>
</g>
<!-- C. MODE: FITNESS (STEPS + RESTING HR) -->
<g v-else>
<!-- Steps bars -->
<g v-for="item in chartItems" :key="item.day.date">
<rect
:x="item.x"
:y="item.ySteps"
:width="item.groupWidth"
:height="item.stepsHeight"
class="fill-emerald-400 hover:fill-emerald-300 transition"
rx="3"
/>
<text
:x="item.xCenter"
:y="chartHeight - paddingBottom + 14"
text-anchor="middle"
class="fill-slate-500 text-[9px] font-mono"
>
{{ item.day.weekday }}
</text>
</g>
<!-- Resting HR Curve Line -->
<path
v-if="hrCurvePath"
:d="hrCurvePath"
fill="none"
stroke="#f43f5e"
stroke-width="2.5"
stroke-linecap="round"
/>
<circle
v-for="item in chartItems"
:key="'hr-' + item.day.date"
v-show="item.yHR !== null"
:cx="item.xCenter"
:cy="item.yHR || 0"
r="3.5"
fill="#f43f5e"
stroke="#ffffff"
stroke-width="1.5"
/>
</g>
</svg>
</div>
<!-- Science Insights & Legend Bar -->
<div class="pt-3 border-t border-slate-100 flex flex-wrap items-center justify-between gap-3 text-xs">
<!-- Mode specific legends -->
<div v-if="activeMode === 'balance'" class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-sky-500 shrink-0"></span>
<span class="text-slate-600 font-medium">Intake (kcal)</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-amber-500 shrink-0"></span>
<span class="text-slate-600 font-medium">Garmin TDEE (kcal)</span>
</div>
</div>
<div v-else-if="activeMode === 'macros'" class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-sky-500 shrink-0"></span>
<span class="text-slate-600 font-medium">Protein</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-amber-400 shrink-0"></span>
<span class="text-slate-600 font-medium">Carbs</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-rose-500 shrink-0"></span>
<span class="text-slate-600 font-medium">Fett</span>
</div>
</div>
<div v-else class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded bg-emerald-400 shrink-0"></span>
<span class="text-slate-600 font-medium">Schritte</span>
</div>
<div class="flex items-center gap-1.5">
<span class="w-3.5 h-1 rounded bg-rose-500 shrink-0"></span>
<span class="text-slate-600 font-medium">Ruhepuls (bpm)</span>
</div>
</div>
<!-- Metabolic Science Formula Indicator -->
<div class="flex items-center gap-2 font-mono text-xs">
<span class="text-slate-500">
Kumulierte Bilanz:
<strong :class="summary.totalNet < 0 ? 'text-emerald-700' : 'text-amber-700'">
{{ summary.totalNet > 0 ? '+' : '' }}{{ summary.totalNet.toLocaleString('de-DE') }} kcal
</strong>
</span>
<span class="px-2 py-0.5 rounded-md bg-emerald-50 text-emerald-800 border border-emerald-200 text-[11px] font-sans font-semibold">
≈ {{ summary.fatKg > 0 ? `+${summary.fatKg}` : summary.fatKg }} kg Fettgewebe
</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,429 @@
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { PlusCircle, Utensils, Check, X, Sparkles, Search } from 'lucide-vue-next'
import { logNutritionIntake, fetchFoods, getLocalDateString } from '../api'
import type { FoodEntity } from '../types'
defineProps<{
isOpen: boolean
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'logged'): void
}>()
const isSubmitting = ref(false)
const errorMessage = ref<string | null>(null)
const successMessage = ref<string | null>(null)
// Target date for retroactive logging
const targetDate = ref(getLocalDateString())
// Form fields
const item = ref('')
const qty = ref<number | ''>('')
const unit = ref('g')
const meal = ref('snack')
const kcal = ref<number | ''>('')
const protein = ref<number | ''>('')
const carbs = ref<number | ''>('')
const fat = ref<number | ''>('')
const mealTime = ref('')
const note = ref('')
// Foods database
const foodsList = ref<FoodEntity[]>([])
const selectedFood = ref<FoodEntity | null>(null)
onMounted(async () => {
foodsList.value = await fetchFoods()
})
const filteredFoods = computed(() => {
if (!item.value.trim() || item.value.trim().length < 2) return []
const query = item.value.toLowerCase().trim()
return foodsList.value
.filter(f => f.title.toLowerCase().includes(query) || (f.brand && f.brand.toLowerCase().includes(query)))
.slice(0, 6)
})
function selectFood(f: FoodEntity) {
selectedFood.value = f
item.value = f.brand ? `${f.title} (${f.brand})` : f.title
unit.value = f.default_unit || 'g'
qty.value = f.default_portion_g || 100
recomputeMacrosFromFood(f, Number(qty.value))
}
function recomputeMacrosFromFood(f: FoodEntity, currentQty: number) {
if (!f.per_100g) return
const factor = (currentQty || 100) / 100
if (f.per_100g.kcal !== undefined) kcal.value = Math.round(f.per_100g.kcal * factor)
if (f.per_100g.protein !== undefined) protein.value = Math.round(f.per_100g.protein * factor * 10) / 10
if (f.per_100g.carbs !== undefined) carbs.value = Math.round(f.per_100g.carbs * factor * 10) / 10
if (f.per_100g.fat !== undefined) fat.value = Math.round(f.per_100g.fat * factor * 10) / 10
}
watch(qty, (newQty) => {
if (selectedFood.value && typeof newQty === 'number') {
recomputeMacrosFromFood(selectedFood.value, newQty)
}
})
const QUICK_ITEMS = [
{
title: 'Porridge-Mischung',
desc: '50g Porridge + 50g Beeren + 200ml Haferdrink',
qty: 300,
unit: 'g',
kcal: 337,
protein_g: 14,
carbs_g: 41,
fat_g: 11,
meal: 'fruehstueck',
},
{
title: 'Koro Proteinriegel',
desc: '~1/3 Riegel',
qty: 18,
unit: 'g',
kcal: 70,
protein_g: 5.5,
carbs_g: 4.5,
fat_g: 4,
meal: 'snack',
},
{
title: 'Körniger Frischkäse (150g)',
desc: 'EDEKA Bio Herzstücke',
qty: 150,
unit: 'g',
kcal: 129,
protein_g: 18,
carbs_g: 3,
fat_g: 5,
meal: 'snack',
},
{
title: 'Quark mit Beeren',
desc: '250g Magerquark + 100g Beeren',
qty: 350,
unit: 'g',
kcal: 210,
protein_g: 30,
carbs_g: 18,
fat_g: 1,
meal: 'snack',
},
{
title: 'Espresso / Kaffee schwarz',
desc: 'Ohne Milch / Zucker',
qty: 1,
unit: 'Tasse',
kcal: 2,
protein_g: 0,
carbs_g: 0,
fat_g: 0,
meal: 'snack',
},
]
function fillQuickItem(q: typeof QUICK_ITEMS[0]) {
selectedFood.value = null
item.value = q.title
qty.value = q.qty
unit.value = q.unit
kcal.value = q.kcal
protein.value = q.protein_g
carbs.value = q.carbs_g
fat.value = q.fat_g
meal.value = q.meal
}
async function quickLog(q: typeof QUICK_ITEMS[0]) {
errorMessage.value = null
successMessage.value = null
isSubmitting.value = true
try {
await logNutritionIntake({
item: q.title,
qty: q.qty,
unit: q.unit,
kcal: q.kcal,
protein_g: q.protein_g,
carbs_g: q.carbs_g,
fat_g: q.fat_g,
meal: q.meal,
date: targetDate.value || undefined,
})
successMessage.value = `"${q.title}" erfolgreich erfasst!`
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Erfassen'
} finally {
isSubmitting.value = false
}
}
async function handleSubmit() {
if (!item.value.trim()) {
errorMessage.value = 'Bitte eine Bezeichnung für die Mahlzeit eingeben.'
return
}
errorMessage.value = null
successMessage.value = null
isSubmitting.value = true
try {
await logNutritionIntake({
item: item.value.trim(),
qty: Number(qty.value) || undefined,
unit: unit.value,
kcal: Number(kcal.value) || 0,
protein_g: Number(protein.value) || 0,
carbs_g: Number(carbs.value) || 0,
fat_g: Number(fat.value) || 0,
meal: meal.value,
time: mealTime.value || undefined,
date: targetDate.value || undefined,
note: note.value.trim() || undefined,
})
successMessage.value = 'Mahlzeit erfolgreich gespeichert!'
emit('logged')
setTimeout(() => emit('close'), 700)
} catch (err: any) {
errorMessage.value = err.message || 'Fehler beim Speichern'
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-slate-900/60 backdrop-blur-sm">
<div class="w-full sm:max-w-xl bg-white rounded-t-3xl sm:rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-xl bg-emerald-500/10 text-emerald-600 flex items-center justify-center">
<Utensils class="w-4 h-4" />
</div>
<div>
<h2 class="text-base font-bold text-slate-900 leading-tight">Mahlzeit erfassen</h2>
<p class="text-[11px] text-slate-500">Ernährungs-Tracking in Knowledge Wiki</p>
</div>
</div>
<button @click="emit('close')" class="p-1.5 rounded-xl text-slate-400 hover:text-slate-700 hover:bg-slate-100 transition">
<X class="w-5 h-5" />
</button>
</div>
<!-- Date Selection Bar -->
<div class="px-6 py-2.5 bg-slate-50 border-b border-slate-100 flex items-center justify-between text-xs text-slate-600">
<span class="flex items-center gap-1.5 font-medium">
<span>📅</span>
<span>Datum des Eintrags:</span>
</span>
<input
v-model="targetDate"
type="date"
class="bg-white border border-slate-200 rounded-lg px-2.5 py-1 text-xs text-slate-900 font-mono focus:border-emerald-500 focus:outline-none shadow-xs"
/>
</div>
<!-- Messages -->
<div v-if="errorMessage" class="mx-6 mt-4 p-3 rounded-2xl bg-rose-50 border border-rose-200 text-rose-700 text-xs">
{{ errorMessage }}
</div>
<div v-if="successMessage" class="mx-6 mt-4 p-3 rounded-2xl bg-emerald-50 border border-emerald-200 text-emerald-800 text-xs flex items-center gap-2">
<Check class="w-4 h-4 text-emerald-600" />
<span>{{ successMessage }}</span>
</div>
<!-- Body -->
<div class="p-6 overflow-y-auto space-y-5 flex-1">
<!-- 1-Tap Schnellwahl -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-bold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<Sparkles class="w-3.5 h-3.5 text-amber-500" />
1-Tap Favoriten & Snacks
</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div
v-for="q in QUICK_ITEMS"
:key="q.title"
class="p-3 rounded-2xl bg-slate-50 border border-slate-200/80 hover:border-emerald-300 hover:bg-emerald-50/30 transition flex items-center justify-between group"
>
<div @click="fillQuickItem(q)" class="cursor-pointer flex-1 pr-2">
<div class="text-xs font-bold text-slate-800 group-hover:text-emerald-700">{{ q.title }}</div>
<div class="text-[10px] text-slate-400">{{ q.desc }}</div>
<div class="text-[10px] font-mono text-emerald-600 font-semibold mt-0.5">
{{ q.kcal }} kcal · {{ q.protein_g }}g P · {{ q.carbs_g }}g C
</div>
</div>
<button
@click="quickLog(q)"
:disabled="isSubmitting"
title="Jetzt loggen"
class="px-2.5 py-1.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-[11px] font-bold shadow-xs active:scale-95 transition"
>
+ Jetzt
</button>
</div>
</div>
</div>
<!-- Detailed Form -->
<form @submit.prevent="handleSubmit" class="space-y-4 pt-4 border-t border-slate-100">
<div class="text-xs font-bold uppercase tracking-wider text-slate-400">
Detaillierte Eingabe & Wiki-Suche
</div>
<!-- Item Search with Autocomplete Dropdown -->
<div class="relative">
<label class="text-xs font-semibold text-slate-600 block mb-1">Bezeichnung / Lebensmittel</label>
<div class="relative">
<input
v-model="item"
type="text"
placeholder="z.B. Skyr, Haferflocken, Banane..."
class="w-full bg-slate-50 border border-slate-200 rounded-xl pl-9 pr-3.5 py-2.5 text-xs text-slate-900 focus:bg-white focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500 transition outline-none"
/>
<Search class="w-4 h-4 text-slate-400 absolute left-3 top-3 pointer-events-none" />
</div>
<!-- Suggestions list -->
<div
v-if="filteredFoods.length > 0"
class="absolute left-0 right-0 mt-1 bg-white border border-slate-200 rounded-2xl shadow-xl z-20 overflow-hidden divide-y divide-slate-100"
>
<div
v-for="f in filteredFoods"
:key="f.id"
@click="selectFood(f)"
class="p-2.5 px-3.5 hover:bg-emerald-50/50 cursor-pointer flex items-center justify-between text-xs transition"
>
<div>
<span class="font-bold text-slate-800">{{ f.title }}</span>
<span v-if="f.brand" class="text-slate-400 ml-1">({{ f.brand }})</span>
</div>
<div v-if="f.per_100g" class="font-mono text-[11px] text-emerald-600">
{{ f.per_100g.kcal }} kcal / 100g · {{ f.per_100g.protein }}g P
</div>
</div>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="text-xs font-semibold text-slate-600 block mb-1">Menge</label>
<input
v-model="qty"
type="number"
placeholder="200"
class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 font-mono outline-none"
/>
</div>
<div>
<label class="text-xs font-semibold text-slate-600 block mb-1">Einheit</label>
<select v-model="unit" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 outline-none">
<option value="g">Gramm (g)</option>
<option value="ml">Milliliter (ml)</option>
<option value="Portion">Portion</option>
<option value="Stück">Stück</option>
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate-600 block mb-1">Mahlzeit</label>
<select v-model="meal" class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 outline-none">
<option value="fruehstueck">Frühstück</option>
<option value="mittagessen">Mittagessen</option>
<option value="abendessen">Abendessen</option>
<option value="snack">Snack</option>
</select>
</div>
<div>
<label class="text-xs font-semibold text-slate-600 block mb-1">Uhrzeit</label>
<input
v-model="mealTime"
type="time"
class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2 text-xs text-slate-900 outline-none"
/>
</div>
</div>
<!-- Nährwerte -->
<div class="grid grid-cols-4 gap-2.5 p-3 rounded-2xl bg-slate-50 border border-slate-100">
<div>
<label class="text-[11px] font-bold text-slate-700 block mb-1">Kalorien</label>
<div class="relative">
<input
v-model="kcal"
type="number"
placeholder="kcal"
class="w-full bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-900 font-mono outline-none"
/>
</div>
</div>
<div>
<label class="text-[11px] font-bold text-sky-700 block mb-1">Protein</label>
<input
v-model="protein"
type="number"
step="0.5"
placeholder="g"
class="w-full bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-900 font-mono outline-none"
/>
</div>
<div>
<label class="text-[11px] font-bold text-amber-700 block mb-1">Carbs</label>
<input
v-model="carbs"
type="number"
step="0.5"
placeholder="g"
class="w-full bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-900 font-mono outline-none"
/>
</div>
<div>
<label class="text-[11px] font-bold text-rose-700 block mb-1">Fett</label>
<input
v-model="fat"
type="number"
step="0.5"
placeholder="g"
class="w-full bg-white border border-slate-200 rounded-lg px-2.5 py-1.5 text-xs text-slate-900 font-mono outline-none"
/>
</div>
</div>
<div>
<label class="text-xs font-semibold text-slate-600 block mb-1">Notiz (optional)</label>
<input
v-model="note"
type="text"
placeholder="z.B. vor dem Laufen gegessen"
class="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2 text-xs text-slate-900 outline-none"
/>
</div>
<button
type="submit"
:disabled="isSubmitting"
class="w-full py-3 rounded-2xl bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-sm shadow-md shadow-emerald-600/20 active:scale-95 transition flex items-center justify-center gap-2"
>
<PlusCircle class="w-4 h-4" />
<span>Mahlzeit speichern</span>
</button>
</form>
</div>
</div>
</div>
</template>
+25
View File
@@ -0,0 +1,25 @@
/// <reference types="vite/client" />
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')
// Reliable Service Worker registration (catches readyState complete and load)
if ('serviceWorker' in navigator) {
const registerSW = () => {
navigator.serviceWorker
.register('/tracking/moritz/sw.js', { scope: '/tracking/moritz/' })
.then((reg) => {
// Check for updates
reg.update().catch(() => {})
})
.catch((err) => console.warn('Moritz Service Worker registration failed:', err))
}
if (document.readyState === 'complete') {
registerSW()
} else {
window.addEventListener('load', registerSW)
}
}
+12
View File
@@ -0,0 +1,12 @@
@import "tailwindcss";
@layer base {
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-tap-highlight-color: transparent;
}
.font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
+149
View File
@@ -0,0 +1,149 @@
export interface MealItem {
id?: string
time: string
item: string
qty: number | null
unit: string
protein_g: number
carbs_g: number
fat_g: number
fiber_g: number
kcal: number
estimated?: boolean
}
export interface FoodEntity {
id: string
title: string
brand?: string
default_portion_g?: number
default_unit?: string
per_100g?: {
kcal?: number
protein?: number
carbs?: number
fat?: number
fiber?: number
}
}
export interface MoritzDayDetail {
date: string
weekday: string
nutrition: {
total_kcal: number
protein_g: number
carbs_g: number
fat_g: number
meals: MealItem[]
}
garmin?: {
total_kcal?: number
active_kcal?: number
bmr_kcal?: number
steps?: number
resting_hr?: number
}
balance_kcal?: number | null
}
export interface DayBar {
date: string
weekday: string
intake_kcal: number
expenditure_kcal: number
active_kcal: number
steps: number
balance_kcal: number | null
protein_g: number
carbs_g: number
fat_g: number
resting_hr: number | null
}
export interface HistoryDay {
date: string
weekday: string
intake_kcal: number
expenditure_kcal: number
active_kcal: number
rmr_kcal: number
steps: number
balance_kcal: number | null
balance_status: string
protein_g: number
carbs_g: number
fat_g: number
resting_hr: number | null
}
export interface MoritzDashboardData {
status: string
execution_time_ms: number
git_status: string
git_conflict: boolean
metadata: {
subject: string
today_date: string
today_pretty: string
yesterday_date: string
yesterday_pretty: string
sleep_date_label: string
}
today: {
total_kcal: number
protein_g: number
carbs_g: number
fat_g: number
item_count: number
meals: MealItem[]
}
yesterday: {
intake_kcal: number
expenditure_kcal: number
active_kcal: number
rmr_kcal: number
steps: number
balance_kcal: number | null
balance_status: string
protein_g: number
carbs_g: number
fat_g: number
resting_hr: number | null
meals: MealItem[]
}
garmin_recovery: {
sleep_dur_str: string
sleep_score: number | null
sleep_qualifier: string
hrv_val: number | null
hrv_status: string
bb_change: number | null
resting_hr: number | null
deep_minutes: number
light_minutes: number
rem_minutes: number
awake_minutes: number
deep_pct: number
light_pct: number
rem_pct: number
awake_pct: number
}
seven_days: {
days: DayBar[]
avg_intake_kcal: number
avg_expenditure_kcal: number
avg_balance_kcal: number
avg_steps: number
avg_protein_g: number
}
thirty_days: {
days: HistoryDay[]
avg_intake_kcal: number
avg_expenditure_kcal: number
avg_balance_kcal: number
total_balance_kcal: number
avg_steps: number
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
base: '/tracking/moritz/',
plugins: [
vue(),
tailwindcss(),
],
server: {
port: 5174,
proxy: {
'/duckdb-api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})