diff --git a/.gitignore b/.gitignore index 6339b74..653a6a6 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ tmp/ #.claude Konfiguration .claude/ -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.jsonfrontend/package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index 3360784..73a075f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1134 +1,248 @@ # Mitai Jinkendo – Entwickler-Kontext für Claude Code ## Projekt-Übersicht -**Mitai Jinkendo** (身体 Jinkendo) ist eine selbst-gehostete PWA für Körper-Tracking (Gewicht, Körperfett, Umfänge, Ernährung, Aktivität) mit KI-Auswertung. Teil der **Jinkendo**-App-Familie (人拳道 – Der menschliche Weg der Kampfkunst). - -**Produktfamilie:** mitai · miken · ikigai · shinkan · kenkou (alle unter jinkendo.de) +**Mitai Jinkendo** (身体 Jinkendo) – selbst-gehostete PWA für Körper-Tracking mit KI-Auswertung. +Teil der **Jinkendo**-App-Familie (人拳道). Domains: jinkendo.de / .com / .life ## Tech-Stack -| Komponente | Technologie | Version | -|-----------|-------------|---------| -| Frontend | React 18 + Vite + PWA | Node 20 | -| Backend | FastAPI (Python) | Python 3.12 | -| Datenbank | PostgreSQL 16 (Alpine) | v9b | -| Container | Docker + Docker Compose | - | -| Webserver | nginx (Reverse Proxy) | Alpine | -| Auth | Token-basiert + bcrypt | - | -| KI | OpenRouter API (claude-sonnet-4) | - | +| Komponente | Technologie | +|-----------|-------------| +| Frontend | React 18 + Vite + PWA (Node 20) | +| Backend | FastAPI Python 3.12 | +| Datenbank | PostgreSQL 16 Alpine | +| Container | Docker + Docker Compose | +| Auth | Token-basiert + bcrypt | +| KI | OpenRouter API (claude-sonnet-4) | -## Ports -| Service | Prod | Dev | -|---------|------|-----| -| Frontend | 3002 | 3099 | -| Backend | 8002 | 8099 | +**Ports:** Prod 3002/8002 · Dev 3099/8099 – nie ändern! ## Verzeichnisstruktur ``` -mitai-jinkendo/ -├── backend/ -│ ├── main.py # FastAPI App Setup + Router Registration (~75 Zeilen) -│ ├── db.py # PostgreSQL Connection Pool + Helpers -│ ├── auth.py # Auth Functions (hash, verify, sessions) -│ ├── models.py # Pydantic Models (11 Models) -│ ├── routers/ # Modular Endpoint Structure (14 Router) -│ │ ├── auth.py # Login, Logout, Password Reset (7 Endpoints) -│ │ ├── profiles.py # Profile CRUD + Current User (7 Endpoints) -│ │ ├── weight.py # Weight Tracking (5 Endpoints) -│ │ ├── circumference.py # Body Measurements (4 Endpoints) -│ │ ├── caliper.py # Skinfold Tracking (4 Endpoints) -│ │ ├── activity.py # Workout Logging + CSV Import (6 Endpoints) -│ │ ├── nutrition.py # Nutrition + FDDB Import (4 Endpoints) -│ │ ├── photos.py # Progress Photos (3 Endpoints) -│ │ ├── insights.py # AI Analysis + Pipeline (8 Endpoints) -│ │ ├── prompts.py # AI Prompt Management (2 Endpoints) -│ │ ├── admin.py # User Management (7 Endpoints) -│ │ ├── stats.py # Dashboard Stats (1 Endpoint) -│ │ ├── exportdata.py # CSV/JSON/ZIP Export (3 Endpoints) -│ │ └── importdata.py # ZIP Import (1 Endpoint) -│ ├── requirements.txt -│ └── Dockerfile -├── frontend/ -│ ├── src/ -│ │ ├── App.jsx # Root, Auth-Gates, Navigation -│ │ ├── app.css # Globale Styles, CSS-Variablen -│ │ ├── context/ -│ │ │ ├── AuthContext.jsx # Session, Login, Logout -│ │ │ └── ProfileContext.jsx # Aktives Profil -│ │ ├── pages/ # Alle Screens -│ │ └── utils/ -│ │ ├── api.js # Alle API-Calls (injiziert Token automatisch) -│ │ ├── calc.js # Körperfett-Formeln -│ │ ├── interpret.js # Regelbasierte Auswertung -│ │ ├── Markdown.jsx # Eigener MD-Renderer -│ │ └── guideData.js # Messanleitungen -│ └── public/ # Icons (Jinkendo Ensō-Logo) -├── .gitea/workflows/ -│ ├── deploy-prod.yml # Auto-Deploy bei Push auf main -│ ├── deploy-dev.yml # Auto-Deploy bei Push auf develop -│ └── test.yml # Build-Test bei jedem Push -├── docker-compose.yml # Produktion (Ports 3002/8002) -├── docker-compose.dev-env.yml # Development (Ports 3099/8099) -└── CLAUDE.md # Diese Datei +backend/ +├── main.py # App-Setup + Router-Registration (~75 Zeilen) +├── db.py # PostgreSQL Connection Pool +├── auth.py # Hash, Verify, Sessions, Feature-Access-Control +├── models.py # Pydantic Models +├── feature_logger.py # Strukturiertes JSON-Logging (Phase 2) +└── routers/ # 14 Router-Module + auth · profiles · weight · circumference · caliper + activity · nutrition · photos · insights · prompts + admin · stats · exportdata · importdata + +frontend/src/ +├── App.jsx # Root, Auth-Gates, Navigation +├── app.css # CSS-Variablen + globale Styles +├── context/ # AuthContext · ProfileContext +├── pages/ # Alle Screens +└── utils/ + api.js # ALLE API-Calls – Token automatisch injiziert + calc.js · interpret.js · Markdown.jsx · guideData.js + +.claude/ +├── settings.json +├── commands/ # /deploy /merge-to-prod /refactor /ui-responsive etc. +└── docs/ + ├── BACKLOG.md + ├── functional/ # Fachliche Specs (TRAINING_TYPES, AI_PROMPTS, RESPONSIVE_UI) + └── technical/ # MEMBERSHIP_SYSTEM.md ``` -## Aktuelle Version: v9b - -### Was implementiert ist: -- ✅ Multi-User mit E-Mail + Passwort Login (bcrypt) -- ✅ Auth-Middleware auf ALLE Endpoints (60+ Endpoints geschützt) -- ✅ Rate Limiting (Login: 5/min, Reset: 3/min) -- ✅ CORS konfigurierbar via ALLOWED_ORIGINS in .env -- ✅ Admin/User Rollen, KI-Limits, Export-Berechtigungen -- ✅ Gewicht, Umfänge, Caliper (4 Formeln), Ernährung, Aktivität -- ✅ FDDB CSV-Import (Ernährung), Apple Health CSV-Import (Aktivität) -- ✅ KI-Analyse: 6 Einzel-Prompts + 3-stufige Pipeline (parallel) -- ✅ Konfigurierbare Prompts mit Template-Variablen (Admin kann bearbeiten) -- ✅ Verlauf mit 5 Tabs + Zeitraumfilter + KI pro Sektion -- ✅ Dashboard mit Kennzahlen, Zielfortschritt, Combo-Chart -- ✅ Assistent-Modus (Schritt-für-Schritt Messung) -- ✅ PWA (iPhone Home Screen), Jinkendo Ensō-Logo -- ✅ E-Mail (SMTP) für Password-Recovery -- ✅ Admin-Panel: User verwalten, KI-Limits, E-Mail-Test, PIN/Email setzen -- ✅ Multi-Environment: Prod (mitai.jinkendo.de) + Dev (dev.mitai.jinkendo.de) -- ✅ Gitea CI/CD mit Auto-Deploy auf Raspberry Pi 5 -- ✅ PostgreSQL 16 Migration (vollständig von SQLite migriert) -- ✅ Export: CSV, JSON, ZIP (mit Fotos) -- ✅ Automatische SQLite→PostgreSQL Migration bei Container-Start -- ✅ **Modulare Backend-Architektur**: 14 Router-Module, main.py von 1878→75 Zeilen (-96%) - -### Was in v9c kommt: Subscription & Coupon Management System -**Phase 1 (DB-Schema): ✅ DONE** -**Phase 2 (Backend API): ✅ DONE** -**Phase 3 (Frontend UI): 🔲 TODO** - -**Core Features (Backend):** -- ✅ DB-Schema (11 neue Tabellen, Feature-Registry Pattern) -- ✅ Feature-Access Middleware (check_feature_access, increment_feature_usage) -- ✅ Flexibles Tier-System (free/basic/premium/selfhosted) - Admin-editierbar via API -- ✅ **Coupon-System** (3 Typen: single_use, period, wellpass) -- ✅ Coupon-Stacking-Logik (Pause + Resume bei Wellpass-Override) -- ✅ Access-Grant-System (zeitlich begrenzte Zugriffe mit Quelle-Tracking) -- ✅ User-Activity-Log (JSONB details) -- ✅ User-Stats (Aggregierte Statistiken) -- ✅ Individuelle User-Restrictions (Admin kann Limits pro User setzen) -- ✅ 7 neue Router, 30+ neue Endpoints (subscription, coupons, features, tiers, tier-limits, user-restrictions, access-grants) - -**Frontend TODO (Phase 3):** -- 🔲 Selbst-Registrierung mit E-Mail-Verifizierung (Pflicht) -- 🔲 Trial-System UI (Dauer konfigurierbar, auto-start nach E-Mail-Verifikation) -- 🔲 Admin Matrix-Editor (Tier x Feature Limits) -- 🔲 Admin Coupon-Manager (CRUD, Redemption-Historie) -- 🔲 Admin User-Restrictions UI -- 🔲 User Subscription-Info Page -- 🔲 User Coupon-Einlösung UI -- 🔲 App-Settings Admin-Panel (globale Konfiguration) - -**E-Mail Templates (v9c):** -- 🔲 Registrierung + E-Mail-Verifizierung -- 🔲 Einladungslink -- 🔲 Passwort-Reset (bereits vorhanden) - -**Spätere Features (v9d/v9e):** -- 🔲 Bonus-System (Login-Streaks → Punkte → Geschenk-Coupons) -- 🔲 Trial-Reminder-E-Mails (3 Tage vor Ablauf) -- 🔲 Monatliches Nutzungs-Summary per E-Mail -- 🔲 Self-Service Upgrade (Stripe-Integration) -- 🔲 Partner-Verwaltung (Wellpass, Hansefit, etc.) -- 🔲 Admin-Benachrichtigungen (neue Registrierungen, etc.) - -### Was in v9d kommt: -- 🔲 Bonus-System & Gamification (Streaks, Achievements) -- 🔲 Stripe-Integration (Self-Service Upgrade, Subscriptions) -- 🔲 OAuth2-Grundgerüst für Fitness-Connectoren -- 🔲 Strava Connector -- 🔲 Withings Connector (Waage) -- 🔲 Garmin Connector - ---- - -## v9c Architektur-Details: Subscription & Coupon System - -### Datenbank-Schema (Neue Tabellen) - -#### **app_settings** - Globale Konfiguration -```sql -key, value, value_type, description, updated_at, updated_by - -Beispiele: -- trial_days: 14 -- trial_behavior: 'downgrade' | 'lock' -- allow_registration: true/false -- default_tier_trial: 'premium' -- gift_coupons_per_month: 3 -``` - -#### **tiers** - Tier-Konfiguration (vereinfacht) -```sql -id, slug, name, description, sort_order, active - -Initial Tiers: -- free, basic, premium, selfhosted - -Limits sind jetzt in tier_limits Tabelle (siehe unten)! -``` - -#### **features** - Feature-Registry (alle limitierbaren Features) -```sql -id, slug, name, category, description, unit -default_limit (NULL = unbegrenzt) -reset_period ('monthly' | 'daily' | 'never') -visible_in_admin, sort_order, active - -Initial Features: -- weight_entries: Gewichtseinträge, default: 30, never -- circumference_entries: Umfangsmessungen, default: 30, never -- caliper_entries: Caliper-Messungen, default: 30, never -- nutrition_entries: Ernährungseinträge, default: 30, never -- activity_entries: Aktivitäten, default: 30, never -- photos: Progress-Fotos, default: 5, never -- ai_calls: KI-Analysen, default: 0, monthly -- ai_pipeline: KI-Pipeline, default: 0, monthly -- csv_import: CSV-Importe, default: 0, monthly -- data_export: Daten-Exporte, default: 0, monthly -- fitness_connectors: Fitness-Connectoren, default: 0, never - -Neue Features einfach per INSERT hinzufügen - kein Schema-Change! -``` - -#### **tier_limits** - Limits pro Tier + Feature -```sql -id, tier_slug, feature_slug, limit_value, enabled - -Beispiel Free Tier: -- ('free', 'weight_entries', 30, true) -- ('free', 'ai_calls', 0, false) -- KI deaktiviert -- ('free', 'data_export', 0, false) - -Beispiel Premium: -- ('premium', 'weight_entries', NULL, true) -- unbegrenzt -- ('premium', 'ai_calls', NULL, true) -- unbegrenzt - -Admin kann in UI Matrix bearbeiten: Tier x Feature -``` - -#### **user_feature_restrictions** - Individuelle User-Limits -```sql -id, profile_id, feature_slug, limit_value, enabled -reason, set_by (admin_id) - -Überschreibt Tier-Limits für spezifische User. -Admin kann jeden User individuell einschränken oder erweitern. -``` - -#### **user_feature_usage** - Nutzungs-Tracking -```sql -id, profile_id, feature_slug, period_start, usage_count, last_used - -Für Features mit reset_period (z.B. ai_calls monthly). -Wird automatisch zurückgesetzt am Monatsanfang. -``` - -#### **coupons** - Coupon-Verwaltung -```sql -code, type ('single_use' | 'multi_use_period' | 'gift') -valid_from, valid_until, grants_tier, duration_days -max_redemptions, current_redemptions -created_by, created_for, notes, active - -Beispiel Single-Use: - Code: FRIEND-GIFT-XYZ, 30 Tage Premium, max 1x - -Beispiel Multi-Use Period: - Code: WELLPASS-2026-03, gültig 01.03-31.03, unbegrenzte Einlösungen -``` - -#### **coupon_redemptions** - Einlösungs-Historie -```sql -coupon_id, profile_id, redeemed_at, access_grant_id -UNIQUE(coupon_id, profile_id) - User kann denselben Coupon nur 1x einlösen -``` - -#### **access_grants** - Zeitlich begrenzte Zugriffe -```sql -profile_id, granted_tier, valid_from, valid_until -source ('coupon' | 'admin_grant' | 'trial') -active (false wenn pausiert durch Wellpass-Override) -paused_at, paused_by (access_grant_id das pausiert hat) - -Stacking-Logik: -- Multi-Use Period Coupon (Wellpass): pausiert andere grants -- Single-Use Coupon: stackt zeitlich (Resume nach Ablauf) -``` - -#### **user_activity_log** - Aktivitäts-Tracking -```sql -profile_id, activity_type, details (JSONB), ip_address, user_agent, created - -Activity Types: -- login, password_change, email_change, coupon_redeemed -- tier_change, export, ai_analysis, registration -``` - -#### **user_stats** - Aggregierte Statistiken -```sql -profile_id, first_login, last_login, total_logins -current_streak_days, longest_streak_days, last_streak_date -total_weight_entries, total_ai_analyses, total_exports -bonus_points (später), gift_coupons_available (später) -``` - -#### **profiles** - Erweiterte Spalten -```sql -tier, tier_locked (Admin kann Tier festnageln) -trial_ends_at, email_verified, email_verify_token -invited_by, contract_type, contract_valid_until -stripe_customer_id (vorbereitet für v9d) -``` - ---- - -### Backend-Erweiterungen - -#### Neue Router (v9c): -``` -routers/tiers.py - Tier-Verwaltung (List, Edit, Create) -routers/features.py - Feature-Registry (List, Add, Edit, Delete) ⭐ NEU -routers/tier_limits.py - Tier-Limits-Matrix (Admin bearbeitet Tier x Feature) ⭐ NEU -routers/coupons.py - Coupon-System (Redeem, Admin CRUD) -routers/access_grants.py - Zugriffs-Verwaltung (Current, Grant, Revoke) -routers/user_admin.py - Erweiterte User-Verwaltung (Activity, Stats, Feature-Restrictions) -routers/settings.py - App-Einstellungen (Admin) -routers/registration.py - Registrierung + E-Mail-Verifizierung -``` - -#### Neue Middleware: -```python -check_feature_access(profile_id, feature_slug, action='use') - """ - Zentrale Feature-Access-Prüfung. - Hierarchie: - 1. User-Restriction (höchste Priorität) - 2. Tier-Limit - 3. Feature-Default - - Returns: {'allowed': bool, 'limit': int, 'used': int, 'remaining': int, 'reason': str} - """ - -increment_feature_usage(profile_id, feature_slug) - """ - Inkrementiert Nutzungszähler. - Berücksichtigt reset_period (monthly, daily, never). - """ - -log_activity(profile_id, activity_type, details=None) - """ - Loggt User-Aktivitäten in user_activity_log. - """ -``` - -#### Hintergrund-Tasks (Cron): -```python -check_expired_access() # Täglich 00:00 - Trial/Coupon-Ablauf prüfen -reset_monthly_limits() # 1. jeden Monats - AI-Calls zurücksetzen -update_user_streaks() # Täglich 23:59 - Login-Streaks aktualisieren -``` - ---- - -### Zugriffs-Hierarchie - -``` -Effektiver Tier wird ermittelt durch (Priorität absteigend): -1. Admin-Override (tier_locked=true) → nutzt profiles.tier -2. Aktiver access_grant (nicht pausiert, valid_until > now) -3. Trial (trial_ends_at > now) -4. Base tier (profiles.tier) - -Wellpass-Override-Logik: -- User hat Single-Use Coupon (20 Tage verbleibend) -- User löst Wellpass-Coupon ein (gültig bis 31.03) -- Single-Use access_grant wird pausiert (active=false, paused_by=wellpass_grant_id) -- Nach Wellpass-Ablauf: Single-Use wird reaktiviert (noch 20 Tage) -``` - ---- - -### Tier-Limits & Feature-Gates - -**Daten-Sichtbarkeit bei Downgrade:** -- Frontend: Buttons/Features ausblenden (Export, KI, Import) -- Backend: API limitiert Rückgabe (z.B. nur letzte 30 Gewichtseinträge bei free) -- Daten bleiben erhalten, werden nur versteckt -- Bei Upgrade wieder sichtbar - -**Feature-Checks:** -```python -# Beispiel: Gewicht-Eintrag erstellen -@check_feature_limit('weight', 'create') -def create_weight_entry(): - # Prüft: Hat User max_weight_entries erreicht? - # Falls ja: HTTPException 403 "Limit erreicht - Upgrade erforderlich" -``` - ---- - -### Frontend-Erweiterungen - -#### Neue Seiten: -``` -RegisterPage.jsx - Registrierung (Name, E-Mail, Passwort) -VerifyEmailPage.jsx - E-Mail-Verifizierung (Token aus URL) -RedeemCouponPage.jsx - Coupon-Eingabe (oder Modal) -AdminCouponsPage.jsx - Coupon-Verwaltung (Admin) -AdminTiersPage.jsx - Tier-Verwaltung (CRUD) (Admin) -AdminFeaturesPage.jsx - Feature-Registry (List, Add, Edit) ⭐ NEU -AdminTierLimitsPage.jsx - Tier x Feature Matrix (bearbeiten) ⭐ NEU -AdminUserRestrictionsPage.jsx - User-spezifische Limits (bearbeiten) ⭐ NEU -AdminSettingsPage.jsx - App-Einstellungen (Admin) -``` - -#### Neue Komponenten: -```jsx - // Tier-Anzeige mit Icon -... // Feature-basierte Sichtbarkeit ⭐ GEÄNDERT - // "Trial endet in 5 Tagen" Banner - // Coupon-Eingabefeld - // User-Activity-Log - // "5/10 verwendet" Anzeige ⭐ NEU - // Matrix-Editor ⭐ NEU - // Login-Streak (später) -``` - -#### Erweiterte Admin-Seiten: -``` -AdminUsersPage.jsx erweitert um: -- Activity-Log Button → zeigt user_activity_log -- Stats Button → zeigt user_stats -- Access-Grants Button → zeigt aktive/abgelaufene Zugriffe -- Feature-Restrictions Button → individuelle Feature-Limits setzen ⭐ GEÄNDERT -- Grant Access Button → manuell Tier-Zugriff gewähren -- Usage-Overview → zeigt user_feature_usage für alle Features ⭐ NEU -``` - -#### Admin-Interface-Details: - -**AdminFeaturesPage.jsx** - Feature-Registry verwalten -```jsx -// Alle Features auflisten + neue hinzufügen - - {features.map(f => ( - - {f.name} - {f.category} - {f.unit} - {f.reset_period} - {f.default_limit ?? '∞'} - - - - - - ))} - - -``` - -**AdminTierLimitsPage.jsx** - Matrix-Editor -```jsx -// Matrix-View: Tiers (Spalten) x Features (Zeilen) - - - - Feature - Free - Basic - Premium - Selfhosted - - - - - Gewichtseinträge - - - - - - - KI-Analysen/Monat - 0 - - ∞ - ∞ - - - -``` - -**AdminUserRestrictionsPage.jsx** - Individuelle User-Limits -```jsx - - - - - {features.map(f => ( - - {f.name} - {getTierLimit(user.tier, f.slug)} - {getUsage(user.id, f.slug)} - - - - - - ))} - -``` - ---- - -### E-Mail Templates (v9c) - -**1. Registrierung + E-Mail-Verifizierung:** -``` -Betreff: Willkommen bei Mitai Jinkendo - E-Mail bestätigen - -Hallo {name}, - -vielen Dank für deine Registrierung bei Mitai Jinkendo! - -Bitte bestätige deine E-Mail-Adresse: -{app_url}/verify-email?token={token} - -Nach der Bestätigung startet dein 14-Tage Premium Trial automatisch. - -Viel Erfolg bei deinem Training! -Dein Mitai Jinkendo Team -``` - -**2. Einladungslink (Admin):** -``` -Betreff: Du wurdest zu Mitai Jinkendo eingeladen - -Hallo, - -{admin_name} hat dich zu Mitai Jinkendo eingeladen! - -Registriere dich jetzt: -{app_url}/register?invite={token} - -Du erhältst {tier} Zugriff. - -Dein Mitai Jinkendo Team -``` - ---- - -### Migrations-Reihenfolge (v9c) - -``` -Phase 1 - DB Schema: -1. app_settings Tabelle + Initialdaten -2. tiers Tabelle + 4 Standard-Tiers -3. coupons Tabelle -4. coupon_redemptions Tabelle -5. access_grants Tabelle -6. user_activity_log Tabelle -7. user_stats Tabelle -8. user_restrictions Tabelle -9. profiles Spalten erweitern -10. Bestehende Profile migrieren (Lars → tier='selfhosted', email_verified=true) - -Phase 2 - Backend: -11. Tier-System Router + Middleware -12. Registrierungs-Flow -13. Coupon-System -14. Access-Grant-Logik -15. Activity-Logging -16. Erweiterte Admin-Endpoints - -Phase 3 - Frontend: -17. Registrierungs-Seiten -18. Tier-System UI-Komponenten -19. Coupon-Eingabe -20. Erweiterte Admin-Panels -21. Feature-Gates in bestehende Seiten einbauen - -Phase 4 - Cron-Jobs: -22. Expired-Access-Checker -23. Monthly-Reset -24. Streak-Updater - -Phase 5 - Testing & Deployment: -25. Dev-Testing -26. Prod-Deployment -``` - ---- +## Aktuelle Version: v9c (komplett) + +### Implementiert ✅ +- Login (E-Mail + bcrypt), Auth-Middleware alle Endpoints, Rate Limiting +- Gewicht · Umfänge · Caliper · Ernährung · Aktivität + CSV-Imports +- KI-Analyse: 6 Prompts + 3-stufige Pipeline +- Dashboard · Verlauf · Assistent-Modus · Fotos +- Admin-Panel · E-Mail (SMTP) · PWA +- PostgreSQL 16 · Modulare Router-Architektur +- Membership-System: Tiers · Coupons · Access-Grants · Admin-UI +- Export: CSV · JSON · ZIP +- **Feature-Enforcement (komplett):** Alle 11 Features mit Monitoring, UI-Badges + Blocking +- **Ernährungs-Modul (erweitert):** + - Manuelles Erfassungsformular mit Upsert-Logik (verhindert Duplikate) + - Import-Historie (CSV-Importe gruppiert nach Datum) + - Bearbeiten/Löschen von Einträgen (inline) + - Datumsfilter (7/30/90/365 Tage, Alle) + - Zwei-Ebenen-Layout: Eingabe (Einzelerfassung/Import) + Auswertung (Charts) + +### Feature-Enforcement Status (4-Phasen-Modell) +- ✅ **Phase 1:** Cleanup (Feature-Konsolidierung, Migration) +- ✅ **Phase 2:** Non-blocking Monitoring (JSON-Logs, alle 11 Features) +- ✅ **Phase 3:** Frontend Display (Usage-Badges, Quota-Übersicht, Hover-Tooltips) +- ✅ **Phase 4:** Enforcement (HTTP 403 bei Limit-Überschreitung, alle Features) + +**Abgedeckte Features:** weight_entries, circumference_entries, caliper_entries, activity_entries, nutrition_entries, photos, ai_calls, ai_pipeline, data_export, data_import + +### Bug-Fixes (v9c) +- ✅ **BUG-001:** TypeError in `/api/nutrition/weekly` (datetime.date vs string handling) +- ✅ **BUG-002:** Ernährungs-Daten Tab fehlte – importierte Einträge nicht sichtbar + +### Offen v9d 🔲 +- Selbst-Registrierung + E-Mail-Verifizierung +- Trial-System UI +- Schlaf-Modul +- Trainingstypen + Herzfrequenz + +📚 Details: `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` · `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` + +## Feature-Roadmap + +> Vollständiges Backlog: `.claude/docs/BACKLOG.md` +> Beim Implementieren: verlinkte Dok-Datei zuerst lesen! + +| Version | Feature | Dokumentation | +|---------|---------|---------------| +| v9c | Membership (aktiv) | `technical/MEMBERSHIP_SYSTEM.md` ✅ | +| v9d | Schlaf-Modul | `functional/SLEEP_MODULE.md` (ausstehend) | +| v9d | Trainingstypen + HF | `functional/TRAINING_TYPES.md` ✅ | +| v9e | Ziele + Vitalwerte | `functional/GOALS_VITALS.md` (ausstehend) | +| v9f | KI-Prompt Flexibilisierung | `functional/AI_PROMPTS.md` ✅ | +| v9g | Meditation + Selbstwahrnehmung | `functional/MEDITATION.md` (ausstehend) | +| v9h | Connectoren + Stripe | ausstehend | +| — | Responsive UI | `functional/RESPONSIVE_UI.md` ✅ | ## Deployment -### Infrastruktur ``` -Internet → privat.stommer.com (Fritz!Box DynDNS) - → Synology NAS (Reverse Proxy + Let's Encrypt) - → Raspberry Pi 5 (192.168.2.49, Docker) +Internet → Fritz!Box (privat.stommer.com) → Synology NAS → Raspberry Pi 5 (192.168.2.49) + +Git Workflow: + develop → Auto-Deploy → dev.mitai.jinkendo.de (bodytrack-dev/, Port 3099/8099) + main → Auto-Deploy → mitai.jinkendo.de (bodytrack/, Port 3002/8002) + +Gitea: http://192.168.2.144:3000/Lars/mitai-jinkendo +Runner: Raspberry Pi (/home/lars/gitea-runner/) + +Manuell: + cd /home/lars/docker/bodytrack[-dev] + docker compose -f docker-compose[.dev-env].yml build --no-cache && up -d ``` -### Git Workflow +## Datenbank-Schema (PostgreSQL 16) ``` -develop branch → Auto-Deploy → dev.mitai.jinkendo.de (Port 3099/8099) -main branch → Auto-Deploy → mitai.jinkendo.de (Port 3002/8002) +profiles – Nutzer (role, pin_hash/bcrypt, email, tier) +sessions – Auth-Tokens +weight_log – Gewicht (profile_id, date, weight) +circumference_log – 8 Umfangspunkte +caliper_log – Hautfalten, 4 Methoden +nutrition_log – Kalorien + Makros +activity_log – Training +photos – Progress-Fotos +ai_insights – KI-Auswertungen (scope = prompt-slug) +ai_prompts – Konfigurierbare Prompts (11 Standard) +ai_usage – KI-Calls pro Tag pro Profil + +v9c neu (Membership): +subscriptions · coupons · coupon_redemptions · features +tier_limits · user_feature_restrictions · user_feature_usage +access_grants · user_activity_log + +Feature-Logging (Phase 2): +/app/logs/feature-usage.log # JSON-Format, alle Feature-Zugriffe + +Schema-Datei: backend/schema.sql ``` -### Deployment-Befehle (manuell falls nötig) -```bash -# Prod -cd /home/lars/docker/bodytrack -docker compose -f docker-compose.yml build --no-cache -docker compose -f docker-compose.yml up -d - -# Dev -cd /home/lars/docker/bodytrack-dev -docker compose -f docker-compose.dev-env.yml build --no-cache -docker compose -f docker-compose.dev-env.yml up -d +## API & Auth ``` +Alle Endpoints: /api/... +Auth-Header: X-Auth-Token: +Fehler: {"detail": "Fehlermeldung"} +Rate Limit: HTTP 429 -## Datenbank-Schema (PostgreSQL 16, v9b) -### Wichtige Tabellen: -- `profiles` – Nutzer (role, pin_hash/bcrypt, email, auth_type, ai_enabled, tier) -- `sessions` – Auth-Tokens mit Ablaufdatum -- `weight_log` – Gewichtseinträge (profile_id, date, weight) -- `circumference_log` – 8 Umfangspunkte -- `caliper_log` – Hautfaltenmessung, 4 Methoden -- `nutrition_log` – Kalorien + Makros (aus FDDB-CSV) -- `activity_log` – Training (aus Apple Health oder manuell) -- `photos` – Progress Photos -- `ai_insights` – KI-Auswertungen (scope = prompt-slug) -- `ai_prompts` – Konfigurierbare Prompts mit Templates (11 Prompts) -- `ai_usage` – KI-Calls pro Tag pro Profil - -**Schema-Datei:** `backend/schema.sql` (vollständiges PostgreSQL-Schema) -**Migration-Script:** `backend/migrate_to_postgres.py` (SQLite→PostgreSQL, automatisch) - -## Auth-Flow (v9b) +Auth-Flow: + Login → E-Mail + Passwort → Token in localStorage + Token → X-Auth-Token Header → require_auth() + Profile-Id → immer aus Session, nie aus Header! + SHA256 → automatisch zu bcrypt migriert beim Login ``` -Login-Screen → E-Mail + Passwort → Token im localStorage -Token → X-Auth-Token Header → Backend require_auth() -Profile-Id → aus Session (nicht aus Header!) -SHA256 Passwörter → automatisch zu bcrypt migriert beim Login -``` - -## API-Konventionen -- Alle Endpoints: `/api/...` -- Auth-Header: `X-Auth-Token: ` -- Responses: immer JSON -- Fehler: `{"detail": "Fehlermeldung"}` -- Rate Limit überschritten: HTTP 429 ## Umgebungsvariablen (.env) ``` -# Database (PostgreSQL) -DB_HOST=postgres -DB_PORT=5432 -DB_NAME=mitai_prod -DB_USER=mitai_prod -DB_PASSWORD= # REQUIRED - -# AI -OPENROUTER_API_KEY= # KI-Calls (optional, alternativ ANTHROPIC_API_KEY) +DB_HOST/PORT/NAME/USER/PASSWORD # PostgreSQL +OPENROUTER_API_KEY # KI OPENROUTER_MODEL=anthropic/claude-sonnet-4 -ANTHROPIC_API_KEY= # Direkte Anthropic API (optional) - -# Email -SMTP_HOST= # E-Mail (für Recovery) -SMTP_PORT=587 -SMTP_USER= -SMTP_PASS= -SMTP_FROM= - -# App +SMTP_HOST/PORT/USER/PASS/FROM # E-Mail APP_URL=https://mitai.jinkendo.de ALLOWED_ORIGINS=https://mitai.jinkendo.de -DATA_DIR=/app/data PHOTOS_DIR=/app/photos -ENVIRONMENT=production ``` -## Wichtige Hinweise für Claude Code -1. **Ports immer 3002/8002 (Prod) oder 3099/8099 (Dev)** – nie ändern -2. **npm install** (nicht npm ci) – kein package-lock.json vorhanden -3. **PostgreSQL-Migrations** – Schema-Änderungen in `backend/schema.sql`, dann Container neu bauen -4. **Pipeline-Prompts** haben slug-Prefix `pipeline_` – nie als Einzelanalyse zeigen -5. **dayjs.week()** braucht Plugin – stattdessen native JS ISO-Wochenberechnung -6. **useNavigate()** nur in React-Komponenten, nicht in Helper-Functions -7. **api.js nutzen** für alle API-Calls – injiziert Token automatisch -8. **bcrypt** für alle neuen Passwort-Operationen verwenden -9. **session=Depends(require_auth)** als separater Parameter – nie in Header() einbetten -10. **RealDictCursor verwenden** – `get_cursor(conn)` statt `conn.cursor()` für dict-like row access +## Kritische Regeln für Claude Code -## Code-Style -- React: Functional Components, Hooks -- CSS: Inline-Styles + globale CSS-Variablen (var(--accent), var(--text1), etc.) -- API-Calls: immer über `api.js` (injiziert Token automatisch) -- Kein TypeScript (bewusst, für Einfachheit) -- Python: keine Type-Hints Pflicht, aber bei neuen Funktionen erwünscht +### Must-Do: +1. `api.js` für ALLE API-Calls nutzen – nie direktes `fetch()` ohne Token +2. `session: dict = Depends(require_auth)` als **separater** Parameter – nie in `Header()` einbetten +3. `bcrypt` für alle Passwort-Operationen +4. Neue DB-Spalten nur via Schema-Migration, nicht direkt +5. `npm install` (nicht npm ci) – kein package-lock.json -## Design-System +### Bekannte Fallstricke: +```python +# ❌ FALSCH – führt zu ungeschütztem Endpoint: +def endpoint(x: str = Header(default=None, session=Depends(require_auth))): -### Farben (CSS-Variablen) -```css ---accent: #1D9E75 /* Jinkendo Grün – Buttons, Links, Akzente */ ---accent-dark: #085041 /* Dunkelgrün – Icon-Hintergrund, Header */ ---accent-light: #E1F5EE /* Hellgrün – Hintergründe, Badges */ ---bg: /* Seitenhintergrund (hell/dunkel auto) */ ---surface: /* Card-Hintergrund */ ---surface2: /* Sekundäre Fläche */ ---border: /* Rahmen */ ---text1: /* Primärer Text */ ---text2: /* Sekundärer Text */ ---text3: /* Muted Text, Labels */ ---danger: #D85A30 /* Fehler, Warnungen */ +# ✅ RICHTIG: +def endpoint(x: str = Header(default=None), session: dict = Depends(require_auth)): ``` -### CSS-Klassen -```css -.card /* Weißer Container, border-radius 12px, box-shadow */ -.btn /* Basis-Button */ -.btn-primary /* Grüner Button (#1D9E75) */ -.btn-secondary /* Grauer Button */ -.btn-full /* 100% Breite */ -.form-input /* Eingabefeld, volle Breite */ -.form-label /* Feldbezeichnung, klein, uppercase */ -.form-row /* Label + Input + Unit nebeneinander */ -.form-unit /* Einheit rechts (kg, cm, etc.) */ -.section-gap /* margin-bottom zwischen Sektionen */ -.spinner /* Ladekreis, animiert */ -``` - -### Abstände & Größen -``` -Seiten-Padding: 16px seitlich -Card-Padding: 16-20px -Border-Radius: 12px (Cards), 8px (Buttons/Inputs), 50% (Avatare) -Icon-Größe: 16-20px inline, 24px standalone -Font-Größe: 12px (Labels), 14px (Body), 16-18px (Subtitel), 20-24px (Titel) -Font-Weight: 400 (normal), 600 (semi-bold), 700 (bold) -Bottom-Padding: 80px (für Mobile-Navigation) -``` - -### Komponenten-Muster - -**Titelzeile einer Seite:** -```jsx -
-
- Seitentitel -
- -
-``` - -**Ladezustand:** -```jsx -if (loading) return ( -
-
-
-) -``` - -**Fehlerzustand:** -```jsx -if (error) return ( -
- {error} -
-) -``` - -**Leerer Zustand:** -```jsx -{items.length === 0 && ( -
-
📭
-
Noch keine Einträge
-
-)} -``` - -**Metric Card:** -```jsx -
-
LABEL
-
- {value} -
-
Einheit
-
-``` - -### Jinkendo Logo-System -``` -Grundelement: Ensō-Kreis (offen, Lücke 4-5 Uhr) -Farbe Ensō: #1D9E75 -Hintergrund: #085041 (dunkelgrün) -Kern-Symbol: #5DCAA5 (mintgrün) -Wortmarke: Jin(light) + ken(bold #1D9E75) + do(light) -``` - -### Verfügbare Custom Commands -``` -/deploy → Commit + Push vorbereiten -/merge-to-prod → develop → main mergen -/test → Manuelle Tests durchführen -/new-feature → Neues Feature-Template -/ui-component → Neue Komponente erstellen -/ui-page → Neue Seite erstellen -/fix-bug → Bug analysieren und beheben -/add-endpoint → Neuen API-Endpoint hinzufügen -/db-add-column → Neue DB-Spalte hinzufügen -``` - -## Jinkendo App-Familie & Markenarchitektur - -### Philosophie -**Jinkendo** (人拳道) = Jin (人 Mensch) + Ken (拳 Faust) + Do (道 Weg) -"Der menschliche Weg der Kampfkunst" – ruhig aber kraftvoll, Selbstwahrnehmung, Meditation, Zielorientiert - -### App-Familie (Subdomain-Architektur) -``` -mitai.jinkendo.de → Körper-Tracker (身体 = eigener Körper) ← DIESE APP -miken.jinkendo.de → Meditation (眉間 = drittes Auge) -ikigai.jinkendo.de → Lebenssinn/Ziele (生き甲斐) -shinkan.jinkendo.de → Kampfsport (真観 = wahre Wahrnehmung) -kenkou.jinkendo.de → Gesundheit allgemein (健康) – für später aufsparen -``` - -### Registrierte Domains -- jinkendo.de, jinkendo.com, jinkendo.life – alle registriert bei Strato - -## v9b Detailplan – Freemium Tier-System - -### Tier-Modell -``` -free → Selbst-Registrierung, 14-Tage Trial, eingeschränkt -basic → Kernfunktionen (Abo Stufe 1) -premium → Alles inkl. KI und Connectoren (Abo Stufe 2) -selfhosted → Lars' Heimversion, keine Einschränkungen -``` - -### Geplante DB-Erweiterungen (profiles Tabelle) -```sql -tier TEXT DEFAULT 'free' -trial_ends_at TEXT -- ISO datetime -sub_valid_until TEXT -- ISO datetime -email_verified INTEGER DEFAULT 0 -email_verify_token TEXT -invited_by TEXT -- profile_id FK -invitation_token TEXT -``` - -### Tier-Limits (geplant) -| Feature | free | basic | premium | selfhosted | -|---------|------|-------|---------|------------| -| Gewicht-Einträge | 30 | unbegrenzt | unbegrenzt | unbegrenzt | -| KI-Analysen/Monat | 0 | 3 | unbegrenzt | unbegrenzt | -| Ernährung Import | ❌ | ✅ | ✅ | ✅ | -| Export | ❌ | ✅ | ✅ | ✅ | -| Fitness-Connectoren | ❌ | ❌ | ✅ | ✅ | - -### Registrierungs-Flow (geplant) -``` -1. Selbst-Registrierung: Name + E-Mail + Passwort -2. Auto-Trial: tier='free', trial_ends_at=now+14d -3. E-Mail-Bestätigung → email_verified=1 -4. Trial läuft ab → Upgrade-Prompt -5. Einladungslinks: Admin generiert Token → direkt basic-Tier -6. Stripe Integration: später (v9b ohne Stripe, nur Tier-Logik) -``` - -## Infrastruktur Details - -### Heimnetzwerk -``` -Internet - → Fritz!Box 7530 AX (DynDNS: privat.stommer.com) - → Synology NAS (192.168.2.63, Reverse Proxy + Let's Encrypt) - → Raspberry Pi 5 (192.168.2.49, Docker) - → MiniPC (192.168.2.144, Gitea auf Port 3000) -``` - -### Synology Reverse Proxy Regeln -``` -mitai.jinkendo.de → HTTP 192.168.2.49:3002 (Prod Frontend) -dev.mitai.jinkendo.de → HTTP 192.168.2.49:3099 (Dev Frontend) -``` - -### AdGuard DNS Rewrites (für internes Routing) -``` -mitai.jinkendo.de → 192.168.2.63 -dev.mitai.jinkendo.de → 192.168.2.63 -``` - -### Fritz!Box DNS-Rebind Ausnahmen -``` -jinkendo.de -mitai.jinkendo.de -``` - -### Pi Verzeichnisstruktur -``` -/home/lars/docker/ -├── bodytrack/ → Prod (main branch, docker-compose.yml) -└── bodytrack-dev/ → Dev (develop branch, docker-compose.dev-env.yml) -``` - -### Gitea Runner -``` -Runner: raspberry-pi (auf Pi installiert) -Service: /etc/systemd/system/gitea-runner.service -Binary: /home/lars/gitea-runner/act_runner -``` - -### Container Namen -``` -Prod: mitai-api, mitai-ui -Dev: dev-mitai-api, dev-mitai-ui -``` - -## Bekannte Probleme & Lösungen - -### Admin User-Erstellung – Email fehlt (v9c TODO) -**Problem:** Bei Admin-CRUD `/api/profiles` (POST) wird keine Email-Adresse abgefragt. -**Impact:** Neue User können sich nicht einloggen (Login erfordert Email). -**Workaround:** Email manuell via `/api/admin/profiles/{pid}/email` setzen. -**Fix-TODO:** POST `/api/profiles` sollte Email als optionales Feld akzeptieren. - -### dayjs.week() – NIEMALS verwenden ```javascript -// ❌ Falsch: -const week = dayjs(date).week() +// ❌ FALSCH – dayjs.week() existiert nicht ohne Plugin: +dayjs(date).week() -// ✅ Richtig (ISO 8601): -const weekNum = (() => { - const dt = new Date(date) - dt.setHours(0,0,0,0) - dt.setDate(dt.getDate()+4-(dt.getDay()||7)) - const y = new Date(dt.getFullYear(),0,1) - return Math.ceil(((dt-y)/86400000+1)/7) -})() +// ✅ RICHTIG – native ISO-Wochenberechnung: +const w = (d => Math.ceil(((new Date(d.setDate(d.getDate()+4-(d.getDay()||7)))- + new Date(d.getFullYear(),0,1))/86400000+1)/7))(new Date(date)) ``` -### session=Depends(require_auth) – Korrekte Platzierung ```python -# ❌ Falsch (führt zu NameError oder ungeschütztem Endpoint): -def endpoint(x_profile_id: Optional[str] = Header(default=None, session=Depends(require_auth))): - -# ✅ Richtig (separater Parameter): -def endpoint(x_profile_id: Optional[str] = Header(default=None), - session: dict = Depends(require_auth)): +# PostgreSQL Boolean (nicht SQLite 0/1): +WHERE active = true # ✅ +WHERE active = 1 # ❌ ``` -### Recharts Bar fill=function – nicht unterstützt -```jsx -// ❌ Falsch: - entry.color}/> +## Design-System (Kurzreferenz) +```css +/* Farben */ +--accent: #1D9E75 --accent-dark: #085041 --danger: #D85A30 +--bg · --surface · --surface2 · --border · --text1 · --text2 · --text3 -// ✅ Richtig: - +/* Klassen */ +.card · .btn · .btn-primary · .btn-secondary · .btn-full +.form-input · .form-label · .form-row · .spinner + +/* Abstände */ +Seiten-Padding: 16px · Card-Padding: 16-20px · Border-Radius: 12px/8px +Bottom-Padding Mobile: 80px (Navigation) ``` -### PostgreSQL Boolean-Syntax -```python -# ❌ Falsch (SQLite-Syntax): -cur.execute("SELECT * FROM ai_prompts WHERE active=1") +> Vollständige CSS-Variablen und Komponenten-Muster: `frontend/src/app.css` +> Responsive Layout-Spec: `.claude/docs/functional/RESPONSIVE_UI.md` -# ✅ Richtig (PostgreSQL): -cur.execute("SELECT * FROM ai_prompts WHERE active=true") +## Dokumentations-Referenzen + +> **Für Claude Code:** Beim Arbeiten an einem Thema die entsprechende Datei lesen: + +| Thema | Datei | +|-------|-------| +| Backend-Architektur, Router, DB-Zugriff | `.claude/docs/architecture/BACKEND.md` | +| Frontend-Architektur, api.js, Komponenten | `.claude/docs/architecture/FRONTEND.md` | +| **Feature-Enforcement (neue Features hinzufügen)** | `.claude/docs/architecture/FEATURE_ENFORCEMENT.md` | +| Coding Rules (Pflichtregeln) | `.claude/docs/rules/CODING_RULES.md` | +| Lessons Learned (Fehler vermeiden) | `.claude/docs/rules/LESSONS_LEARNED.md` | +| Feature Backlog (Übersicht) | `.claude/docs/BACKLOG.md` | +| **Pending Features (noch nicht enforced)** | `.claude/docs/PENDING_FEATURES.md` | +| **Known Issues (Bugs & Tech Debt)** | `.claude/docs/KNOWN_ISSUES.md` | +| Membership-System (v9c, technisch) | `.claude/docs/technical/MEMBERSHIP_SYSTEM.md` | +| Trainingstypen + HF (v9d, fachlich) | `.claude/docs/functional/TRAINING_TYPES.md` | +| KI-Prompt Flexibilisierung (v9f, fachlich) | `.claude/docs/functional/AI_PROMPTS.md` | +| Responsive UI (fachlich) | `.claude/docs/functional/RESPONSIVE_UI.md` | + +## Jinkendo App-Familie ``` - -### RealDictCursor für dict-like row access -```python -# ❌ Falsch: -cur = conn.cursor() -cur.execute("SELECT COUNT(*) FROM weight_log") -count = cur.fetchone()[0] # Tuple index - -# ✅ Richtig: -cur = get_cursor(conn) # Returns RealDictCursor -cur.execute("SELECT COUNT(*) as count FROM weight_log") -count = cur.fetchone()['count'] # Dict key -``` - -## v9b Migration – Lessons Learned - -### PostgreSQL Migration (SQLite → PostgreSQL) -**Problem:** Docker Build hing 30+ Minuten bei `apt-get install postgresql-client` -**Lösung:** Alle apt-get dependencies entfernt, reine Python-Lösung mit psycopg2-binary - -**Problem:** Leere date-Strings (`''`) führten zu PostgreSQL-Fehlern -**Lösung:** Migration-Script konvertiert leere Strings zu NULL für DATE-Spalten - -**Problem:** Boolean-Felder (SQLite INTEGER 0/1 vs PostgreSQL BOOLEAN) -**Lösung:** Migration konvertiert automatisch, Backend nutzt `active=true` statt `active=1` - -### API Endpoint Consistency (März 2026) -**Problem:** 11 kritische Endpoint-Mismatches zwischen Frontend und Backend gefunden -**Gelöst:** -- AI-Endpoints konsistent: `/api/insights/run/{slug}`, `/api/insights/pipeline` -- Password-Reset: `/api/auth/forgot-password`, `/api/auth/reset-password` -- Admin-Endpoints: `/permissions`, `/email`, `/pin` Sub-Routes -- Export: JSON + ZIP Endpoints hinzugefügt -- Prompt-Bearbeitung: PUT-Endpoint für Admins - -**Tool:** Vollständiger Audit via Explore-Agent empfohlen bei größeren Änderungen - -## Export/Import Spezifikation (v9c) - -### ZIP-Export Struktur -``` -mitai-export-{name}-{YYYY-MM-DD}.zip -├── README.txt ← Erklärung des Formats + Versionsnummer -├── profile.json ← Profildaten (ohne Passwort-Hash) -├── data/ -│ ├── weight.csv ← Gewichtsverlauf -│ ├── circumferences.csv ← Umfänge (8 Messpunkte) -│ ├── caliper.csv ← Caliper-Messungen -│ ├── nutrition.csv ← Ernährungsdaten -│ └── activity.csv ← Aktivitäten -├── insights/ -│ └── ai_insights.json ← KI-Auswertungen (alle gespeicherten) -└── photos/ - ├── {date}_{id}.jpg ← Progress-Fotos - └── ... -``` - -### CSV Format (alle Dateien) -``` -- Trennzeichen: Semikolon (;) – Excel/LibreOffice kompatibel -- Encoding: UTF-8 mit BOM (für Windows Excel) -- Datumsformat: YYYY-MM-DD -- Dezimaltrennzeichen: Punkt (.) -- Erste Zeile: Header -- Nullwerte: leer (nicht "null" oder "NULL") -``` - -### weight.csv Spalten -``` -id;date;weight;note;source;created -``` - -### circumferences.csv Spalten -``` -id;date;waist;hip;chest;neck;upper_arm;thigh;calf;forearm;note;created -``` - -### caliper.csv Spalten -``` -id;date;chest;abdomen;thigh;tricep;subscapular;suprailiac;midaxillary;method;bf_percent;note;created -``` - -### nutrition.csv Spalten -``` -id;date;meal_name;kcal;protein;fat;carbs;fiber;note;source;created -``` - -### activity.csv Spalten -``` -id;date;name;type;duration_min;kcal;heart_rate_avg;heart_rate_max;distance_km;note;source;created -``` - -### profile.json Struktur -```json -{ - "export_version": "2", - "export_date": "2026-03-18", - "app": "Mitai Jinkendo", - "profile": { - "name": "Lars", - "email": "lars@stommer.com", - "sex": "m", - "height": 178, - "birth_year": 1980, - "goal_weight": 82, - "goal_bf_pct": 14, - "avatar_color": "#1D9E75", - "auth_type": "password", - "session_days": 30, - "ai_enabled": true, - "tier": "selfhosted" - }, - "stats": { - "weight_entries": 150, - "nutrition_entries": 300, - "activity_entries": 45, - "photos": 12 - } -} -``` - -### ai_insights.json Struktur -```json -[ - { - "id": "uuid", - "scope": "gesamt", - "created": "2026-03-18T10:00:00", - "result": "KI-Analyse Text..." - } -] -``` - -### README.txt Inhalt -``` -Mitai Jinkendo – Datenexport -Version: 2 -Exportiert am: YYYY-MM-DD -Profil: {name} - -Inhalt: -- profile.json: Profildaten und Einstellungen -- data/*.csv: Messdaten (Semikolon-getrennt, UTF-8) -- insights/: KI-Auswertungen (JSON) -- photos/: Progress-Fotos (JPEG) - -Import: -Dieser Export kann in Mitai Jinkendo unter -Einstellungen → Import → "Mitai Backup importieren" -wieder eingespielt werden. - -Format-Version 2 (ab v9b): -Alle CSV-Dateien sind UTF-8 mit BOM kodiert. -Trennzeichen: Semikolon (;) -Datumsformat: YYYY-MM-DD -``` - -### Import-Funktion (zu implementieren) -**Endpoint:** `POST /api/import/zip` -**Verhalten:** -- Akzeptiert ZIP-Datei (multipart/form-data) -- Erkennt export_version aus profile.json -- Importiert nur fehlende Einträge (kein Duplikat) -- Fotos werden nicht überschrieben falls bereits vorhanden -- Gibt Zusammenfassung zurück: wie viele Einträge je Kategorie importiert -- Bei Fehler: vollständiger Rollback (alle oder nichts) - -**Duplikat-Erkennung:** -```python -# INSERT ... ON CONFLICT (profile_id, date) DO NOTHING -# weight: UNIQUE (profile_id, date) -# nutrition: UNIQUE (profile_id, date, meal_name) -# activity: UNIQUE (profile_id, date, name) -# caliper: UNIQUE (profile_id, date) -# circumferences: UNIQUE (profile_id, date) -``` - -**Frontend:** Neuer Button in SettingsPage: -``` -[ZIP exportieren] [JSON exportieren] [Backup importieren] +mitai.jinkendo.de → Körper-Tracker (diese App) +miken.jinkendo.de → Meditation (眉間) +ikigai.jinkendo.de → Lebenssinn (生き甲斐) +shinkan.jinkendo.de → Kampfsport (真観) ``` diff --git a/backend/apply_v9c_migration.py b/backend/apply_v9c_migration.py index 4a3bba5..78a9fca 100644 --- a/backend/apply_v9c_migration.py +++ b/backend/apply_v9c_migration.py @@ -60,6 +60,9 @@ def apply_migration(): if not migration_needed(conn): print("[v9c Migration] Already applied, skipping.") conn.close() + + # Even if main migration is done, check cleanup + apply_cleanup_migration() return print("[v9c Migration] Applying subscription system migration...") @@ -83,6 +86,26 @@ def apply_migration(): print("[v9c Migration] ✅ Migration completed successfully!") + # Apply fix migration if exists + fix_migration_path = os.path.join( + os.path.dirname(__file__), + "migrations", + "v9c_fix_features.sql" + ) + + if os.path.exists(fix_migration_path): + print("[v9c Migration] Applying feature fixes...") + with open(fix_migration_path, 'r', encoding='utf-8') as f: + fix_sql = f.read() + + conn = get_db_connection() + cur = conn.cursor() + cur.execute(fix_sql) + conn.commit() + cur.close() + conn.close() + print("[v9c Migration] ✅ Feature fixes applied!") + # Verify tables created conn = get_db_connection() cur = conn.cursor() @@ -108,10 +131,123 @@ def apply_migration(): cur.close() conn.close() + # After successful migration, apply cleanup + apply_cleanup_migration() + except Exception as e: print(f"[v9c Migration] ❌ Error: {e}") raise +def cleanup_features_needed(conn): + """Check if feature cleanup migration is needed.""" + cur = conn.cursor() + + # Check if old export features still exist + cur.execute(""" + SELECT COUNT(*) as count FROM features + WHERE id IN ('export_csv', 'export_json', 'export_zip') + """) + old_exports = cur.fetchone()['count'] + + # Check if csv_import needs to be renamed + cur.execute(""" + SELECT COUNT(*) as count FROM features + WHERE id = 'csv_import' + """) + old_import = cur.fetchone()['count'] + + cur.close() + + # Cleanup needed if old features exist + return old_exports > 0 or old_import > 0 + + +def apply_cleanup_migration(): + """Apply v9c feature cleanup migration.""" + print("[v9c Cleanup] Checking if cleanup migration is needed...") + + try: + conn = get_db_connection() + + if not cleanup_features_needed(conn): + print("[v9c Cleanup] Already applied, skipping.") + conn.close() + return + + print("[v9c Cleanup] Applying feature consolidation...") + + # Show BEFORE state + cur = conn.cursor() + cur.execute("SELECT id, name FROM features ORDER BY category, id") + features_before = [f"{r['id']} ({r['name']})" for r in cur.fetchall()] + print(f"[v9c Cleanup] Features BEFORE: {len(features_before)} features") + for f in features_before: + print(f" - {f}") + cur.close() + + # Read cleanup migration SQL + cleanup_path = os.path.join( + os.path.dirname(__file__), + "migrations", + "v9c_cleanup_features.sql" + ) + + if not os.path.exists(cleanup_path): + print(f"[v9c Cleanup] ⚠️ Cleanup migration file not found: {cleanup_path}") + conn.close() + return + + with open(cleanup_path, 'r', encoding='utf-8') as f: + cleanup_sql = f.read() + + # Execute cleanup migration + cur = conn.cursor() + cur.execute(cleanup_sql) + conn.commit() + cur.close() + + # Show AFTER state + cur = conn.cursor() + cur.execute("SELECT id, name, category FROM features ORDER BY category, id") + features_after = cur.fetchall() + print(f"[v9c Cleanup] Features AFTER: {len(features_after)} features") + + # Group by category + categories = {} + for f in features_after: + cat = f['category'] or 'other' + if cat not in categories: + categories[cat] = [] + categories[cat].append(f"{f['id']} ({f['name']})") + + for cat, feats in sorted(categories.items()): + print(f" {cat.upper()}:") + for f in feats: + print(f" - {f}") + + # Verify tier_limits updated + cur.execute(""" + SELECT tier_id, feature_id, limit_value + FROM tier_limits + WHERE feature_id IN ('data_export', 'data_import') + ORDER BY tier_id, feature_id + """) + limits = cur.fetchall() + print(f"[v9c Cleanup] Tier limits for data_export/data_import:") + for lim in limits: + limit_str = 'unlimited' if lim['limit_value'] is None else lim['limit_value'] + print(f" {lim['tier_id']}.{lim['feature_id']} = {limit_str}") + + cur.close() + conn.close() + + print("[v9c Cleanup] ✅ Feature cleanup completed successfully!") + + except Exception as e: + print(f"[v9c Cleanup] ❌ Error: {e}") + raise + + if __name__ == "__main__": apply_migration() diff --git a/backend/auth.py b/backend/auth.py index 21b0042..918b5eb 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -121,17 +121,22 @@ def require_admin(x_auth_token: Optional[str] = Header(default=None)): # Feature Access Control (v9c) # ============================================================================ -def get_effective_tier(profile_id: str) -> str: +def get_effective_tier(profile_id: str, conn=None) -> str: """ Get the effective tier for a profile. Checks for active access_grants first (from coupons, trials, etc.), then falls back to profile.tier. + Args: + profile_id: User profile ID + conn: Optional existing DB connection (to avoid pool exhaustion) + Returns: tier_id (str): 'free', 'basic', 'premium', or 'selfhosted' """ - with get_db() as conn: + # Use existing connection if provided, otherwise open new one + if conn: cur = get_cursor(conn) # Check for active access grants (highest priority) @@ -154,9 +159,13 @@ def get_effective_tier(profile_id: str) -> str: cur.execute("SELECT tier FROM profiles WHERE id = %s", (profile_id,)) profile = cur.fetchone() return profile['tier'] if profile else 'free' + else: + # Open new connection if none provided + with get_db() as conn: + return get_effective_tier(profile_id, conn) -def check_feature_access(profile_id: str, feature_id: str) -> dict: +def check_feature_access(profile_id: str, feature_id: str, conn=None) -> dict: """ Check if a profile has access to a feature. @@ -165,6 +174,11 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict: 2. Tier limit (tier_limits) 3. Feature default (features.default_limit) + Args: + profile_id: User profile ID + feature_id: Feature ID to check + conn: Optional existing DB connection (to avoid pool exhaustion) + Returns: dict: { 'allowed': bool, @@ -174,118 +188,127 @@ def check_feature_access(profile_id: str, feature_id: str) -> dict: 'reason': str # 'unlimited', 'within_limit', 'limit_exceeded', 'feature_disabled' } """ - with get_db() as conn: - cur = get_cursor(conn) + # Use existing connection if provided + if conn: + return _check_impl(profile_id, feature_id, conn) + else: + with get_db() as conn: + return _check_impl(profile_id, feature_id, conn) - # Get feature info - cur.execute(""" - SELECT limit_type, reset_period, default_limit - FROM features - WHERE id = %s AND active = true - """, (feature_id,)) - feature = cur.fetchone() - if not feature: - return { - 'allowed': False, - 'limit': None, - 'used': 0, - 'remaining': None, - 'reason': 'feature_not_found' - } +def _check_impl(profile_id: str, feature_id: str, conn) -> dict: + """Internal implementation of check_feature_access.""" + cur = get_cursor(conn) - # Priority 1: Check user-specific restriction + # Get feature info + cur.execute(""" + SELECT limit_type, reset_period, default_limit + FROM features + WHERE id = %s AND active = true + """, (feature_id,)) + feature = cur.fetchone() + + if not feature: + return { + 'allowed': False, + 'limit': None, + 'used': 0, + 'remaining': None, + 'reason': 'feature_not_found' + } + + # Priority 1: Check user-specific restriction + cur.execute(""" + SELECT limit_value + FROM user_feature_restrictions + WHERE profile_id = %s AND feature_id = %s + """, (profile_id, feature_id)) + restriction = cur.fetchone() + + if restriction is not None: + limit = restriction['limit_value'] + else: + # Priority 2: Check tier limit + tier_id = get_effective_tier(profile_id, conn) cur.execute(""" SELECT limit_value - FROM user_feature_restrictions - WHERE profile_id = %s AND feature_id = %s - """, (profile_id, feature_id)) - restriction = cur.fetchone() + FROM tier_limits + WHERE tier_id = %s AND feature_id = %s + """, (tier_id, feature_id)) + tier_limit = cur.fetchone() - if restriction is not None: - limit = restriction['limit_value'] + if tier_limit is not None: + limit = tier_limit['limit_value'] else: - # Priority 2: Check tier limit - tier_id = get_effective_tier(profile_id) - cur.execute(""" - SELECT limit_value - FROM tier_limits - WHERE tier_id = %s AND feature_id = %s - """, (tier_id, feature_id)) - tier_limit = cur.fetchone() - - if tier_limit is not None: - limit = tier_limit['limit_value'] - else: - # Priority 3: Feature default - limit = feature['default_limit'] - - # For boolean features (limit 0 = disabled, 1 = enabled) - if feature['limit_type'] == 'boolean': - allowed = limit == 1 - return { - 'allowed': allowed, - 'limit': limit, - 'used': 0, - 'remaining': None, - 'reason': 'enabled' if allowed else 'feature_disabled' - } - - # For count-based features - # Check current usage - cur.execute(""" - SELECT usage_count, reset_at - FROM user_feature_usage - WHERE profile_id = %s AND feature_id = %s - """, (profile_id, feature_id)) - usage = cur.fetchone() - - used = usage['usage_count'] if usage else 0 - - # Check if reset is needed - if usage and usage['reset_at'] and datetime.now() > usage['reset_at']: - # Reset usage - used = 0 - next_reset = _calculate_next_reset(feature['reset_period']) - cur.execute(""" - UPDATE user_feature_usage - SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP - WHERE profile_id = %s AND feature_id = %s - """, (next_reset, profile_id, feature_id)) - conn.commit() - - # NULL limit = unlimited - if limit is None: - return { - 'allowed': True, - 'limit': None, - 'used': used, - 'remaining': None, - 'reason': 'unlimited' - } - - # 0 limit = disabled - if limit == 0: - return { - 'allowed': False, - 'limit': 0, - 'used': used, - 'remaining': 0, - 'reason': 'feature_disabled' - } - - # Check if within limit - allowed = used < limit - remaining = limit - used if limit else None + # Priority 3: Feature default + limit = feature['default_limit'] + # For boolean features (limit 0 = disabled, 1 = enabled) + if feature['limit_type'] == 'boolean': + allowed = limit == 1 return { 'allowed': allowed, 'limit': limit, - 'used': used, - 'remaining': remaining, - 'reason': 'within_limit' if allowed else 'limit_exceeded' + 'used': 0, + 'remaining': None, + 'reason': 'enabled' if allowed else 'feature_disabled' } + # For count-based features + # Check current usage + cur.execute(""" + SELECT usage_count, reset_at + FROM user_feature_usage + WHERE profile_id = %s AND feature_id = %s + """, (profile_id, feature_id)) + usage = cur.fetchone() + + used = usage['usage_count'] if usage else 0 + + # Check if reset is needed + if usage and usage['reset_at'] and datetime.now() > usage['reset_at']: + # Reset usage + used = 0 + next_reset = _calculate_next_reset(feature['reset_period']) + cur.execute(""" + UPDATE user_feature_usage + SET usage_count = 0, reset_at = %s, updated = CURRENT_TIMESTAMP + WHERE profile_id = %s AND feature_id = %s + """, (next_reset, profile_id, feature_id)) + conn.commit() + + # NULL limit = unlimited + if limit is None: + return { + 'allowed': True, + 'limit': None, + 'used': used, + 'remaining': None, + 'reason': 'unlimited' + } + + # 0 limit = disabled + if limit == 0: + return { + 'allowed': False, + 'limit': 0, + 'used': used, + 'remaining': 0, + 'reason': 'feature_disabled' + } + + # Check if within limit + allowed = used < limit + remaining = limit - used if limit else None + + return { + 'allowed': allowed, + 'limit': limit, + 'used': used, + 'remaining': remaining, + 'reason': 'within_limit' if allowed else 'limit_exceeded' + } + def increment_feature_usage(profile_id: str, feature_id: str) -> None: """ diff --git a/backend/check_features.py b/backend/check_features.py new file mode 100644 index 0000000..bd68e99 --- /dev/null +++ b/backend/check_features.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Quick diagnostic script to check features table.""" + +from db import get_db, get_cursor + +with get_db() as conn: + cur = get_cursor(conn) + + print("\n=== FEATURES TABLE ===") + cur.execute("SELECT id, name, active, limit_type, reset_period FROM features ORDER BY id") + features = cur.fetchall() + + if not features: + print("❌ NO FEATURES FOUND! Migration failed!") + else: + for r in features: + print(f" {r['id']:30} {r['name']:40} active={r['active']} type={r['limit_type']:8} reset={r['reset_period']}") + + print(f"\nTotal features: {len(features)}") + + print("\n=== USER_FEATURE_USAGE (recent) ===") + cur.execute(""" + SELECT profile_id, feature_id, usage_count, reset_at + FROM user_feature_usage + ORDER BY updated DESC + LIMIT 10 + """) + usages = cur.fetchall() + + if not usages: + print(" (no usage records yet)") + else: + for r in usages: + print(f" {r['profile_id'][:8]}... -> {r['feature_id']:30} used={r['usage_count']} reset_at={r['reset_at']}") + + print(f"\nTotal usage records: {len(usages)}") diff --git a/backend/feature_logger.py b/backend/feature_logger.py new file mode 100644 index 0000000..7698397 --- /dev/null +++ b/backend/feature_logger.py @@ -0,0 +1,76 @@ +""" +Feature Usage Logger for Mitai Jinkendo + +Logs all feature access checks to a separate JSON log file for analysis. +Phase 2: Non-blocking monitoring of feature usage. +""" +import logging +import json +from datetime import datetime +from pathlib import Path + + +# ── Setup Feature Usage Logger ─────────────────────────────────────────────── +feature_usage_logger = logging.getLogger('feature_usage') +feature_usage_logger.setLevel(logging.INFO) +feature_usage_logger.propagate = False # Don't propagate to root logger + +# Ensure logs directory exists +LOG_DIR = Path('/app/logs') +LOG_DIR.mkdir(parents=True, exist_ok=True) + +# FileHandler for JSON logs +log_file = LOG_DIR / 'feature-usage.log' +file_handler = logging.FileHandler(log_file) +file_handler.setLevel(logging.INFO) +file_handler.setFormatter(logging.Formatter('%(message)s')) # JSON only +feature_usage_logger.addHandler(file_handler) + +# Also log to console in dev (optional) +# console_handler = logging.StreamHandler() +# console_handler.setFormatter(logging.Formatter('[FEATURE-USAGE] %(message)s')) +# feature_usage_logger.addHandler(console_handler) + + +# ── Logging Function ────────────────────────────────────────────────────────── +def log_feature_usage(user_id: str, feature_id: str, access: dict, action: str): + """ + Log feature usage in structured JSON format. + + Args: + user_id: Profile UUID + feature_id: Feature identifier (e.g., 'weight_entries', 'ai_calls') + access: Result from check_feature_access() containing: + - allowed: bool + - limit: int | None + - used: int + - remaining: int | None + - reason: str + action: Type of action (e.g., 'create', 'export', 'analyze') + + Example log entry: + { + "timestamp": "2026-03-20T15:30:45.123456", + "user_id": "abc-123", + "feature": "weight_entries", + "action": "create", + "used": 5, + "limit": 100, + "remaining": 95, + "allowed": true, + "reason": "within_limit" + } + """ + entry = { + "timestamp": datetime.now().isoformat(), + "user_id": user_id, + "feature": feature_id, + "action": action, + "used": access.get('used', 0), + "limit": access.get('limit'), # None for unlimited + "remaining": access.get('remaining'), # None for unlimited + "allowed": access.get('allowed', True), + "reason": access.get('reason', 'unknown') + } + + feature_usage_logger.info(json.dumps(entry)) diff --git a/backend/migrations/check_features.sql b/backend/migrations/check_features.sql new file mode 100644 index 0000000..70f10fe --- /dev/null +++ b/backend/migrations/check_features.sql @@ -0,0 +1,50 @@ +-- ============================================================================ +-- Feature Check Script - Diagnose vor/nach Migration +-- ============================================================================ +-- Usage: psql -U mitai_dev -d mitai_dev -f check_features.sql +-- ============================================================================ + +\echo '=== CURRENT FEATURES ===' +SELECT id, name, category, limit_type, reset_period, default_limit, active +FROM features +ORDER BY category, id; + +\echo '' +\echo '=== TIER LIMITS MATRIX ===' +SELECT + f.id as feature, + f.category, + MAX(CASE WHEN tl.tier_id = 'free' THEN COALESCE(tl.limit_value::text, '∞') END) as free, + MAX(CASE WHEN tl.tier_id = 'basic' THEN COALESCE(tl.limit_value::text, '∞') END) as basic, + MAX(CASE WHEN tl.tier_id = 'premium' THEN COALESCE(tl.limit_value::text, '∞') END) as premium, + MAX(CASE WHEN tl.tier_id = 'selfhosted' THEN COALESCE(tl.limit_value::text, '∞') END) as selfhosted +FROM features f +LEFT JOIN tier_limits tl ON f.id = tl.feature_id +GROUP BY f.id, f.category +ORDER BY f.category, f.id; + +\echo '' +\echo '=== FEATURE COUNT BY CATEGORY ===' +SELECT category, COUNT(*) as count +FROM features +WHERE active = true +GROUP BY category +ORDER BY category; + +\echo '' +\echo '=== ORPHANED TIER LIMITS (feature not exists) ===' +SELECT tl.tier_id, tl.feature_id, tl.limit_value +FROM tier_limits tl +LEFT JOIN features f ON tl.feature_id = f.id +WHERE f.id IS NULL; + +\echo '' +\echo '=== USER FEATURE USAGE (current usage tracking) ===' +SELECT + p.name as user, + ufu.feature_id, + ufu.usage_count, + ufu.reset_at +FROM user_feature_usage ufu +JOIN profiles p ON ufu.profile_id = p.id +ORDER BY p.name, ufu.feature_id; diff --git a/backend/migrations/v9c_cleanup_features.sql b/backend/migrations/v9c_cleanup_features.sql new file mode 100644 index 0000000..9acfbde --- /dev/null +++ b/backend/migrations/v9c_cleanup_features.sql @@ -0,0 +1,141 @@ +-- ============================================================================ +-- v9c Cleanup: Feature-Konsolidierung +-- ============================================================================ +-- Created: 2026-03-20 +-- Purpose: Konsolidiere Export-Features (export_csv/json/zip → data_export) +-- und Import-Features (csv_import → data_import) +-- +-- Idempotent: Kann mehrfach ausgeführt werden +-- +-- Lessons Learned: +-- "Ein Feature für Export, nicht drei (csv/json/zip)" +-- ============================================================================ + +-- ============================================================================ +-- 1. Rename csv_import to data_import +-- ============================================================================ +UPDATE features +SET + id = 'data_import', + name = 'Daten importieren', + description = 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import' +WHERE id = 'csv_import'; + +-- Update tier_limits references +UPDATE tier_limits +SET feature_id = 'data_import' +WHERE feature_id = 'csv_import'; + +-- Update user_feature_restrictions references +UPDATE user_feature_restrictions +SET feature_id = 'data_import' +WHERE feature_id = 'csv_import'; + +-- Update user_feature_usage references +UPDATE user_feature_usage +SET feature_id = 'data_import' +WHERE feature_id = 'csv_import'; + +-- ============================================================================ +-- 2. Remove old export_csv/json/zip features +-- ============================================================================ + +-- Remove tier_limits for old features +DELETE FROM tier_limits +WHERE feature_id IN ('export_csv', 'export_json', 'export_zip'); + +-- Remove user restrictions for old features +DELETE FROM user_feature_restrictions +WHERE feature_id IN ('export_csv', 'export_json', 'export_zip'); + +-- Remove usage tracking for old features +DELETE FROM user_feature_usage +WHERE feature_id IN ('export_csv', 'export_json', 'export_zip'); + +-- Remove old feature definitions +DELETE FROM features +WHERE id IN ('export_csv', 'export_json', 'export_zip'); + +-- ============================================================================ +-- 3. Ensure data_export exists and is properly configured +-- ============================================================================ +INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) +VALUES ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category, + limit_type = EXCLUDED.limit_type, + reset_period = EXCLUDED.reset_period; + +-- ============================================================================ +-- 4. Ensure data_import exists and is properly configured +-- ============================================================================ +INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) +VALUES ('data_import', 'Daten importieren', 'CSV-Import (FDDB, Apple Health) + ZIP-Backup-Import', 'import', 'count', 'monthly', 0, true) +ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + description = EXCLUDED.description, + category = EXCLUDED.category, + limit_type = EXCLUDED.limit_type, + reset_period = EXCLUDED.reset_period; + +-- ============================================================================ +-- 5. Update tier_limits for data_export (consolidate from old features) +-- ============================================================================ + +-- FREE tier: no export +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('free', 'data_export', 0) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- BASIC tier: 5 exports/month +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('basic', 'data_export', 5) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- PREMIUM tier: unlimited +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('premium', 'data_export', NULL) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- SELFHOSTED tier: unlimited +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('selfhosted', 'data_export', NULL) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- ============================================================================ +-- 6. Update tier_limits for data_import +-- ============================================================================ + +-- FREE tier: no import +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('free', 'data_import', 0) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- BASIC tier: 3 imports/month +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('basic', 'data_import', 3) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- PREMIUM tier: unlimited +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('premium', 'data_import', NULL) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- SELFHOSTED tier: unlimited +INSERT INTO tier_limits (tier_id, feature_id, limit_value) +VALUES ('selfhosted', 'data_import', NULL) +ON CONFLICT (tier_id, feature_id) DO UPDATE SET limit_value = EXCLUDED.limit_value; + +-- ============================================================================ +-- Cleanup complete +-- ============================================================================ +-- Final feature list: +-- Data: weight_entries, circumference_entries, caliper_entries, +-- nutrition_entries, activity_entries, photos +-- AI: ai_calls, ai_pipeline +-- Export/Import: data_export, data_import +-- +-- Total: 10 features (down from 13) +-- ============================================================================ diff --git a/backend/migrations/v9c_fix_features.sql b/backend/migrations/v9c_fix_features.sql new file mode 100644 index 0000000..74382a2 --- /dev/null +++ b/backend/migrations/v9c_fix_features.sql @@ -0,0 +1,33 @@ +-- Fix missing features for v9c feature enforcement +-- 2026-03-20 + +-- Add missing features +INSERT INTO features (id, name, description, category, limit_type, reset_period, default_limit, active) VALUES + ('data_export', 'Daten exportieren', 'CSV/JSON/ZIP Export', 'export', 'count', 'monthly', 0, true), + ('csv_import', 'CSV importieren', 'FDDB/Apple Health CSV Import + ZIP Backup Import', 'import', 'count', 'monthly', 0, true) +ON CONFLICT (id) DO NOTHING; + +-- Add tier limits for new features +-- FREE tier +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES + ('free', 'data_export', 0), -- Kein Export + ('free', 'csv_import', 0) -- Kein Import +ON CONFLICT (tier_id, feature_id) DO NOTHING; + +-- BASIC tier +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES + ('basic', 'data_export', 5), -- 5 Exporte/Monat + ('basic', 'csv_import', 3) -- 3 Imports/Monat +ON CONFLICT (tier_id, feature_id) DO NOTHING; + +-- PREMIUM tier +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES + ('premium', 'data_export', NULL), -- Unbegrenzt + ('premium', 'csv_import', NULL) -- Unbegrenzt +ON CONFLICT (tier_id, feature_id) DO NOTHING; + +-- SELFHOSTED tier +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES + ('selfhosted', 'data_export', NULL), -- Unbegrenzt + ('selfhosted', 'csv_import', NULL) -- Unbegrenzt +ON CONFLICT (tier_id, feature_id) DO NOTHING; diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 8ff768d..0d12000 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -6,16 +6,19 @@ Handles workout/activity logging, statistics, and Apple Health CSV import. import csv import io import uuid +import logging from typing import Optional from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import ActivityEntry from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/activity", tags=["activity"]) +logger = logging.getLogger(__name__) @router.get("") @@ -33,6 +36,22 @@ def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=Non def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create new activity entry.""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'activity_entries') + log_feature_usage(pid, 'activity_entries', access, 'create') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"activity_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Aktivitätseinträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + eid = str(uuid.uuid4()) d = e.model_dump() with get_db() as conn: @@ -44,6 +63,10 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default (eid,pid,d['date'],d['start_time'],d['end_time'],d['activity_type'],d['duration_min'], d['kcal_active'],d['kcal_resting'],d['hr_avg'],d['hr_max'],d['distance_km'], d['rpe'],d['source'],d['notes'])) + + # Phase 2: Increment usage counter (always for new entries) + increment_feature_usage(pid, 'activity_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/caliper.py b/backend/routers/caliper.py index b217a4c..3c53e52 100644 --- a/backend/routers/caliper.py +++ b/backend/routers/caliper.py @@ -4,16 +4,19 @@ Caliper/Skinfold Tracking Endpoints for Mitai Jinkendo Handles body fat measurements via skinfold caliper (4 methods supported). """ import uuid +import logging from typing import Optional -from fastapi import APIRouter, Header, Depends +from fastapi import APIRouter, Header, Depends, HTTPException from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import CaliperEntry from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/caliper", tags=["caliper"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,17 +34,37 @@ def list_caliper(limit: int=100, x_profile_id: Optional[str]=Header(default=None def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update caliper entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'caliper_entries') + log_feature_usage(pid, 'caliper_entries', access, 'create') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"caliper_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Caliper-Einträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM caliper_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() + is_new_entry = not ex + if ex: + # UPDATE existing entry eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE caliper_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: + # INSERT new entry eid = str(uuid.uuid4()) cur.execute("""INSERT INTO caliper_log (id,profile_id,date,sf_method,sf_chest,sf_axilla,sf_triceps,sf_subscap,sf_suprailiac, @@ -50,6 +73,10 @@ def upsert_caliper(e: CaliperEntry, x_profile_id: Optional[str]=Header(default=N (eid,pid,d['date'],d['sf_method'],d['sf_chest'],d['sf_axilla'],d['sf_triceps'], d['sf_subscap'],d['sf_suprailiac'],d['sf_abdomen'],d['sf_thigh'],d['sf_calf_med'], d['sf_lowerback'],d['sf_biceps'],d['body_fat_pct'],d['lean_mass'],d['fat_mass'],d['notes'])) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'caliper_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/circumference.py b/backend/routers/circumference.py index feba22c..41c06fa 100644 --- a/backend/routers/circumference.py +++ b/backend/routers/circumference.py @@ -4,16 +4,19 @@ Circumference Tracking Endpoints for Mitai Jinkendo Handles body circumference measurements (8 measurement points). """ import uuid +import logging from typing import Optional -from fastapi import APIRouter, Header, Depends +from fastapi import APIRouter, Header, Depends, HTTPException from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import CircumferenceEntry from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/circumferences", tags=["circumference"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,23 +34,47 @@ def list_circs(limit: int=100, x_profile_id: Optional[str]=Header(default=None), def upsert_circ(e: CircumferenceEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update circumference entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'circumference_entries') + log_feature_usage(pid, 'circumference_entries', access, 'create') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"circumference_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Umfangs-Einträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM circumference_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() d = e.model_dump() + is_new_entry = not ex + if ex: + # UPDATE existing entry eid = ex['id'] sets = ', '.join(f"{k}=%s" for k in d if k!='date') cur.execute(f"UPDATE circumference_log SET {sets} WHERE id=%s", [v for k,v in d.items() if k!='date']+[eid]) else: + # INSERT new entry eid = str(uuid.uuid4()) cur.execute("""INSERT INTO circumference_log (id,profile_id,date,c_neck,c_chest,c_waist,c_belly,c_hip,c_thigh,c_calf,c_arm,notes,photo_id,created) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)""", (eid,pid,d['date'],d['c_neck'],d['c_chest'],d['c_waist'],d['c_belly'], d['c_hip'],d['c_thigh'],d['c_calf'],d['c_arm'],d['notes'],d['photo_id'])) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'circumference_entries') + return {"id":eid,"date":e.date} diff --git a/backend/routers/exportdata.py b/backend/routers/exportdata.py index 02109ea..d4e6998 100644 --- a/backend/routers/exportdata.py +++ b/backend/routers/exportdata.py @@ -7,6 +7,7 @@ import os import csv import io import json +import logging import zipfile from pathlib import Path from typing import Optional @@ -17,10 +18,12 @@ from fastapi import APIRouter, HTTPException, Header, Depends from fastapi.responses import StreamingResponse, Response from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/export", tags=["export"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) @@ -30,13 +33,20 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as CSV.""" pid = get_pid(x_profile_id) - # Check export permission - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) - prof = cur.fetchone() - if not prof or not prof['export_enabled']: - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'data_export') + log_feature_usage(pid, 'data_export', access, 'export_csv') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Build CSV output = io.StringIO() @@ -74,6 +84,10 @@ def export_csv(x_profile_id: Optional[str]=Header(default=None), session: dict=D writer.writerow(["Training", r['date'], r['activity_type'], f"{float(r['duration_min'])}min {float(r['kcal_active'])}kcal"]) output.seek(0) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", @@ -86,13 +100,20 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= """Export all data as JSON.""" pid = get_pid(x_profile_id) - # Check export permission - with get_db() as conn: - cur = get_cursor(conn) - cur.execute("SELECT export_enabled FROM profiles WHERE id=%s", (pid,)) - prof = cur.fetchone() - if not prof or not prof['export_enabled']: - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'data_export') + log_feature_usage(pid, 'data_export', access, 'export_json') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Collect all data data = {} @@ -126,6 +147,10 @@ def export_json(x_profile_id: Optional[str]=Header(default=None), session: dict= return str(obj) json_str = json.dumps(data, indent=2, default=decimal_handler) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return Response( content=json_str, media_type="application/json", @@ -138,13 +163,26 @@ def export_zip(x_profile_id: Optional[str]=Header(default=None), session: dict=D """Export all data as ZIP (CSV + JSON + photos) per specification.""" pid = get_pid(x_profile_id) - # Check export permission & get profile + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'data_export') + log_feature_usage(pid, 'data_export', access, 'export_zip') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"data_export {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Exporte überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + + # Get profile with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,)) prof = r2d(cur.fetchone()) - if not prof or not prof.get('export_enabled'): - raise HTTPException(403, "Export ist für dieses Profil deaktiviert") # Helper: CSV writer with UTF-8 BOM + semicolon def write_csv(zf, filename, rows, columns): @@ -297,6 +335,10 @@ Datumsformat: YYYY-MM-DD zip_buffer.seek(0) filename = f"mitai-export-{profile_name.replace(' ','-')}-{export_date}.zip" + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_export') + return StreamingResponse( iter([zip_buffer.getvalue()]), media_type="application/zip", diff --git a/backend/routers/features.py b/backend/routers/features.py index 458e8e8..228315a 100644 --- a/backend/routers/features.py +++ b/backend/routers/features.py @@ -2,11 +2,16 @@ Feature Management Endpoints for Mitai Jinkendo Admin-only CRUD for features registry. +User endpoint for feature usage overview (Phase 3). """ -from fastapi import APIRouter, HTTPException, Depends +from typing import Optional +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_admin +from auth import require_admin, require_auth, check_feature_access +from routers.profiles import get_pid router = APIRouter(prefix="/api/features", tags=["features"]) @@ -119,3 +124,100 @@ def delete_feature(feature_id: str, session: dict = Depends(require_admin)): cur.execute("UPDATE features SET active = false WHERE id = %s", (feature_id,)) conn.commit() return {"ok": True} + + +@router.get("/{feature_id}/check-access") +def check_access(feature_id: str, session: dict = Depends(require_auth)): + """ + User: Check if current user can access a feature. + + Returns: + - allowed: bool - whether user can use the feature + - limit: int|null - total limit (null = unlimited) + - used: int - current usage + - remaining: int|null - remaining uses (null = unlimited) + - reason: str - why access is granted/denied + """ + profile_id = session['profile_id'] + result = check_feature_access(profile_id, feature_id) + return result + + +@router.get("/usage") +def get_feature_usage(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """ + User: Get usage overview for all active features (Phase 3: Frontend Display). + + Returns list of all features with current usage, limits, and reset info. + Automatically includes new features from database - no code changes needed. + + Response: + [ + { + "feature_id": "weight_entries", + "name": "Gewichtseinträge", + "description": "Anzahl der Gewichtseinträge", + "category": "data", + "limit_type": "count", + "reset_period": "never", + "used": 5, + "limit": 10, + "remaining": 5, + "allowed": true, + "reset_at": null + }, + ... + ] + """ + pid = get_pid(x_profile_id) + + with get_db() as conn: + cur = get_cursor(conn) + + # Get all active features (dynamic - picks up new features automatically) + cur.execute(""" + SELECT id, name, description, category, limit_type, reset_period + FROM features + WHERE active = true + ORDER BY category, name + """) + features = [r2d(r) for r in cur.fetchall()] + + result = [] + for feature in features: + # Use existing check_feature_access to get usage and limits + # This respects user overrides, tier limits, and feature defaults + # Pass connection to avoid pool exhaustion + access = check_feature_access(pid, feature['id'], conn) + + # Get reset date from user_feature_usage + cur.execute(""" + SELECT reset_at + FROM user_feature_usage + WHERE profile_id = %s AND feature_id = %s + """, (pid, feature['id'])) + usage_row = cur.fetchone() + + # Format reset_at as ISO string + reset_at = None + if usage_row and usage_row['reset_at']: + if isinstance(usage_row['reset_at'], datetime): + reset_at = usage_row['reset_at'].isoformat() + else: + reset_at = str(usage_row['reset_at']) + + result.append({ + 'feature_id': feature['id'], + 'name': feature['name'], + 'description': feature.get('description'), + 'category': feature.get('category'), + 'limit_type': feature['limit_type'], + 'reset_period': feature['reset_period'], + 'used': access['used'], + 'limit': access['limit'], + 'remaining': access['remaining'], + 'allowed': access['allowed'], + 'reset_at': reset_at + }) + + return result diff --git a/backend/routers/importdata.py b/backend/routers/importdata.py index bd78e46..d98a48a 100644 --- a/backend/routers/importdata.py +++ b/backend/routers/importdata.py @@ -8,6 +8,7 @@ import csv import io import json import uuid +import logging import zipfile from pathlib import Path from typing import Optional @@ -16,10 +17,12 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/import", tags=["import"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) @@ -41,6 +44,21 @@ async def import_zip( """ pid = get_pid(x_profile_id) + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'data_import') + log_feature_usage(pid, 'data_import', access, 'import_zip') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"data_import {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Daten-Importe überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + # Read uploaded file content = await file.read() zip_buffer = io.BytesIO(content) @@ -254,6 +272,9 @@ async def import_zip( conn.rollback() raise HTTPException(500, f"Import fehlgeschlagen: {str(e)}") + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'data_import') + return { "ok": True, "message": "Import erfolgreich", diff --git a/backend/routers/insights.py b/backend/routers/insights.py index b325bf0..12a342e 100644 --- a/backend/routers/insights.py +++ b/backend/routers/insights.py @@ -6,6 +6,7 @@ Handles AI analysis execution, prompt management, and usage tracking. import os import json import uuid +import logging import httpx from typing import Optional from datetime import datetime @@ -13,10 +14,12 @@ from datetime import datetime from fastapi import APIRouter, HTTPException, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth, require_admin +from auth import require_auth, require_admin, check_feature_access, increment_feature_usage from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api", tags=["insights"]) +logger = logging.getLogger(__name__) OPENROUTER_KEY = os.getenv("OPENROUTER_API_KEY", "") OPENROUTER_MODEL = os.getenv("OPENROUTER_MODEL", "anthropic/claude-sonnet-4") @@ -251,7 +254,21 @@ def delete_ai_insight(scope: str, x_profile_id: Optional[str]=Header(default=Non async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run AI analysis with specified prompt template.""" pid = get_pid(x_profile_id) - check_ai_limit(pid) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'ai_calls') + log_feature_usage(pid, 'ai_calls', access, 'analyze') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"ai_calls {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) # Get prompt template with get_db() as conn: @@ -294,14 +311,18 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa else: raise HTTPException(500, "Keine KI-API konfiguriert") - # Save insight + # Save insight (with history - no DELETE) with get_db() as conn: cur = get_cursor(conn) - cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope=%s", (pid, slug)) cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, slug, content)) + # Phase 2: Increment new feature usage counter + increment_feature_usage(pid, 'ai_calls') + + # Old usage tracking (keep for now) inc_ai_usage(pid) + return {"scope": slug, "content": content} @@ -309,7 +330,35 @@ async def analyze_with_prompt(slug: str, x_profile_id: Optional[str]=Header(defa async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Run 3-stage pipeline analysis.""" pid = get_pid(x_profile_id) - check_ai_limit(pid) + + # Phase 4: Check pipeline feature access (boolean - enabled/disabled) + access_pipeline = check_feature_access(pid, 'ai_pipeline') + log_feature_usage(pid, 'ai_pipeline', access_pipeline, 'pipeline') + + if not access_pipeline['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"ai_pipeline {access_pipeline['reason']}" + ) + raise HTTPException( + status_code=403, + detail=f"Pipeline-Analyse ist nicht verfügbar. Bitte kontaktiere den Admin." + ) + + # Also check ai_calls (pipeline uses API calls too) + access_calls = check_feature_access(pid, 'ai_calls') + log_feature_usage(pid, 'ai_calls', access_calls, 'pipeline_calls') + + if not access_calls['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"ai_calls {access_calls['reason']} (used: {access_calls['used']}, limit: {access_calls['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für KI-Analysen überschritten ({access_calls['used']}/{access_calls['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) data = _get_profile_data(pid) vars = _prepare_template_vars(data) @@ -431,15 +480,20 @@ async def analyze_pipeline(x_profile_id: Optional[str]=Header(default=None), ses if goals_text: final_content += "\n\n" + goals_text - # Save as 'gesamt' scope + # Save as 'pipeline' scope (with history - no DELETE) with get_db() as conn: cur = get_cursor(conn) - cur.execute("DELETE FROM ai_insights WHERE profile_id=%s AND scope='gesamt'", (pid,)) - cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'gesamt',%s,CURRENT_TIMESTAMP)", + cur.execute("INSERT INTO ai_insights (id, profile_id, scope, content, created) VALUES (%s,%s,'pipeline',%s,CURRENT_TIMESTAMP)", (str(uuid.uuid4()), pid, final_content)) + # Phase 2: Increment ai_calls usage (pipeline uses multiple API calls) + # Note: We increment once per pipeline run, not per individual call + increment_feature_usage(pid, 'ai_calls') + + # Old usage tracking (keep for now) inc_ai_usage(pid) - return {"scope": "gesamt", "content": final_content, "stage1": stage1_results} + + return {"scope": "pipeline", "content": final_content, "stage1": stage1_results} @router.get("/ai/usage") diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index e12b4c0..65d6777 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -6,16 +6,19 @@ Handles nutrition data, FDDB CSV import, correlations, and weekly aggregates. import csv import io import uuid +import logging from typing import Optional from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/nutrition", tags=["nutrition"]) +logger = logging.getLogger(__name__) # ── Helper ──────────────────────────────────────────────────────────────────── @@ -30,6 +33,23 @@ def _pf(s): async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Import FDDB nutrition CSV.""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + # Note: CSV import can create many entries - we check once before import + access = check_feature_access(pid, 'nutrition_entries') + log_feature_usage(pid, 'nutrition_entries', access, 'import_csv') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + raw = await file.read() try: text = raw.decode('utf-8') except: text = raw.decode('latin-1') @@ -52,23 +72,88 @@ async def import_nutrition_csv(file: UploadFile=File(...), x_profile_id: Optiona days[iso]['protein_g'] += _pf(row.get('protein_g',0)) count+=1 inserted=0 + new_entries=0 with get_db() as conn: cur = get_cursor(conn) for iso,vals in days.items(): kcal=round(vals['kcal'],1); fat=round(vals['fat_g'],1) carbs=round(vals['carbs_g'],1); prot=round(vals['protein_g'],1) cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s",(pid,iso)) - if cur.fetchone(): + is_new = not cur.fetchone() + if not is_new: + # UPDATE existing cur.execute("UPDATE nutrition_log SET kcal=%s,protein_g=%s,fat_g=%s,carbs_g=%s WHERE profile_id=%s AND date=%s", (kcal,prot,fat,carbs,pid,iso)) else: + # INSERT new cur.execute("INSERT INTO nutrition_log (id,profile_id,date,kcal,protein_g,fat_g,carbs_g,source,created) VALUES (%s,%s,%s,%s,%s,%s,%s,'csv',CURRENT_TIMESTAMP)", (str(uuid.uuid4()),pid,iso,kcal,prot,fat,carbs)) + new_entries += 1 inserted+=1 - return {"rows_parsed":count,"days_imported":inserted, + + # Phase 2: Increment usage counter for each new entry created + for _ in range(new_entries): + increment_feature_usage(pid, 'nutrition_entries') + + return {"rows_parsed":count,"days_imported":inserted,"new_entries":new_entries, "date_range":{"from":min(days) if days else None,"to":max(days) if days else None}} +@router.post("") +def create_nutrition(date: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float, + x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Create or update nutrition entry for a specific date.""" + pid = get_pid(x_profile_id) + + # Validate date format + try: + datetime.strptime(date, '%Y-%m-%d') + except ValueError: + raise HTTPException(400, "Ungültiges Datumsformat. Erwartet: YYYY-MM-DD") + + with get_db() as conn: + cur = get_cursor(conn) + # Check if entry exists + cur.execute("SELECT id FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date)) + existing = cur.fetchone() + + if existing: + # UPDATE existing entry + cur.execute(""" + UPDATE nutrition_log + SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s, source='manual' + WHERE id=%s AND profile_id=%s + """, (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), existing['id'], pid)) + return {"success": True, "mode": "updated", "id": existing['id']} + else: + # Phase 4: Check feature access before INSERT + access = check_feature_access(pid, 'nutrition_entries') + log_feature_usage(pid, 'nutrition_entries', access, 'create') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"nutrition_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Ernährungseinträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + + # INSERT new entry + new_id = str(uuid.uuid4()) + cur.execute(""" + INSERT INTO nutrition_log (id, profile_id, date, kcal, protein_g, fat_g, carbs_g, source, created) + VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', CURRENT_TIMESTAMP) + """, (new_id, pid, date, round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1))) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'nutrition_entries') + + return {"success": True, "mode": "created", "id": new_id} + + @router.get("") def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get nutrition entries for current profile.""" @@ -80,6 +165,17 @@ def list_nutrition(limit: int=365, x_profile_id: Optional[str]=Header(default=No return [r2d(r) for r in cur.fetchall()] +@router.get("/by-date/{date}") +def get_nutrition_by_date(date: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get nutrition entry for a specific date.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s AND date=%s", (pid, date)) + row = cur.fetchone() + return r2d(row) if row else None + + @router.get("/correlations") def nutrition_correlations(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Get nutrition data correlated with weight and body fat.""" @@ -123,7 +219,9 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N if not rows: return [] wm={} for d in rows: - wk=datetime.strptime(d['date'],'%Y-%m-%d').strftime('%Y-W%V') + # Handle both datetime.date objects (from DB) and strings + date_obj = d['date'] if hasattr(d['date'], 'strftime') else datetime.strptime(d['date'],'%Y-%m-%d') + wk = date_obj.strftime('%Y-W%V') wm.setdefault(wk,[]).append(d) result=[] for wk in sorted(wm): @@ -131,3 +229,61 @@ def nutrition_weekly(weeks: int=16, x_profile_id: Optional[str]=Header(default=N def avg(k): return round(sum(float(e.get(k) or 0) for e in en)/n,1) result.append({'week':wk,'days':n,'kcal':avg('kcal'),'protein_g':avg('protein_g'),'fat_g':avg('fat_g'),'carbs_g':avg('carbs_g')}) return result + + +@router.get("/import-history") +def import_history(x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Get import history by grouping entries by created timestamp.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute(""" + SELECT + DATE(created) as import_date, + COUNT(*) as count, + MIN(date) as date_from, + MAX(date) as date_to, + MAX(created) as last_created + FROM nutrition_log + WHERE profile_id=%s AND source='csv' + GROUP BY DATE(created) + ORDER BY DATE(created) DESC + """, (pid,)) + return [r2d(r) for r in cur.fetchall()] + + +@router.put("/{entry_id}") +def update_nutrition(entry_id: str, kcal: float, protein_g: float, fat_g: float, carbs_g: float, + x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Update nutrition entry macros.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + # Verify ownership + cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid)) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + + cur.execute(""" + UPDATE nutrition_log + SET kcal=%s, protein_g=%s, fat_g=%s, carbs_g=%s + WHERE id=%s AND profile_id=%s + """, (round(kcal,1), round(protein_g,1), round(fat_g,1), round(carbs_g,1), entry_id, pid)) + + return {"success": True} + + +@router.delete("/{entry_id}") +def delete_nutrition(entry_id: str, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): + """Delete nutrition entry.""" + pid = get_pid(x_profile_id) + with get_db() as conn: + cur = get_cursor(conn) + # Verify ownership + cur.execute("SELECT id FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid)) + if not cur.fetchone(): + raise HTTPException(404, "Eintrag nicht gefunden") + + cur.execute("DELETE FROM nutrition_log WHERE id=%s AND profile_id=%s", (entry_id, pid)) + + return {"success": True} diff --git a/backend/routers/photos.py b/backend/routers/photos.py index cb847ec..6fc06f0 100644 --- a/backend/routers/photos.py +++ b/backend/routers/photos.py @@ -5,6 +5,7 @@ Handles progress photo uploads and retrieval. """ import os import uuid +import logging from pathlib import Path from typing import Optional @@ -13,10 +14,12 @@ from fastapi.responses import FileResponse import aiofiles from db import get_db, get_cursor, r2d -from auth import require_auth, require_auth_flexible +from auth import require_auth, require_auth_flexible, check_feature_access, increment_feature_usage from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/photos", tags=["photos"]) +logger = logging.getLogger(__name__) PHOTOS_DIR = Path(os.getenv("PHOTOS_DIR", "./photos")) PHOTOS_DIR.mkdir(parents=True, exist_ok=True) @@ -27,6 +30,22 @@ async def upload_photo(file: UploadFile=File(...), date: str="", x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Upload progress photo.""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'photos') + log_feature_usage(pid, 'photos', access, 'upload') + + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"photos {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Fotos überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + fid = str(uuid.uuid4()) ext = Path(file.filename).suffix or '.jpg' path = PHOTOS_DIR / f"{fid}{ext}" @@ -35,6 +54,10 @@ async def upload_photo(file: UploadFile=File(...), date: str="", cur = get_cursor(conn) cur.execute("INSERT INTO photos (id,profile_id,date,path,created) VALUES (%s,%s,%s,%s,CURRENT_TIMESTAMP)", (fid,pid,date,str(path))) + + # Phase 2: Increment usage counter + increment_feature_usage(pid, 'photos') + return {"id":fid,"date":date} diff --git a/backend/routers/tier_limits.py b/backend/routers/tier_limits.py index 4a0454d..ce980ad 100644 --- a/backend/routers/tier_limits.py +++ b/backend/routers/tier_limits.py @@ -29,13 +29,13 @@ def get_tier_limits_matrix(session: dict = Depends(require_admin)): with get_db() as conn: cur = get_cursor(conn) - # Get all tiers - cur.execute("SELECT id, name, sort_order FROM tiers WHERE active = true ORDER BY sort_order") + # Get all tiers (including inactive - admin needs to configure all) + cur.execute("SELECT id, name, sort_order FROM tiers ORDER BY sort_order") tiers = [r2d(r) for r in cur.fetchall()] # Get all features cur.execute(""" - SELECT id, name, category, limit_type, default_limit + SELECT id, name, category, limit_type, default_limit, reset_period FROM features WHERE active = true ORDER BY category, name diff --git a/backend/routers/weight.py b/backend/routers/weight.py index 0d66713..2d49baa 100644 --- a/backend/routers/weight.py +++ b/backend/routers/weight.py @@ -4,16 +4,19 @@ Weight Tracking Endpoints for Mitai Jinkendo Handles weight log CRUD operations and statistics. """ import uuid +import logging from typing import Optional -from fastapi import APIRouter, Header, Depends +from fastapi import APIRouter, Header, Depends, HTTPException from db import get_db, get_cursor, r2d -from auth import require_auth +from auth import require_auth, check_feature_access, increment_feature_usage from models import WeightEntry from routers.profiles import get_pid +from feature_logger import log_feature_usage router = APIRouter(prefix="/api/weight", tags=["weight"]) +logger = logging.getLogger(__name__) @router.get("") @@ -31,17 +34,44 @@ def list_weight(limit: int=365, x_profile_id: Optional[str]=Header(default=None) def upsert_weight(e: WeightEntry, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)): """Create or update weight entry (upsert by date).""" pid = get_pid(x_profile_id) + + # Phase 4: Check feature access and ENFORCE + access = check_feature_access(pid, 'weight_entries') + + # Structured logging (always) + log_feature_usage(pid, 'weight_entries', access, 'create') + + # BLOCK if limit exceeded + if not access['allowed']: + logger.warning( + f"[FEATURE-LIMIT] User {pid} blocked: " + f"weight_entries {access['reason']} (used: {access['used']}, limit: {access['limit']})" + ) + raise HTTPException( + status_code=403, + detail=f"Limit erreicht: Du hast das Kontingent für Gewichtseinträge überschritten ({access['used']}/{access['limit']}). " + f"Bitte kontaktiere den Admin oder warte bis zum nächsten Reset." + ) + with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM weight_log WHERE profile_id=%s AND date=%s", (pid,e.date)) ex = cur.fetchone() + is_new_entry = not ex + if ex: + # UPDATE existing entry cur.execute("UPDATE weight_log SET weight=%s,note=%s WHERE id=%s", (e.weight,e.note,ex['id'])) wid = ex['id'] else: + # INSERT new entry wid = str(uuid.uuid4()) cur.execute("INSERT INTO weight_log (id,profile_id,date,weight,note,created) VALUES (%s,%s,%s,%s,%s,CURRENT_TIMESTAMP)", (wid,pid,e.date,e.weight,e.note)) + + # Phase 2: Increment usage counter (only for new entries) + increment_feature_usage(pid, 'weight_entries') + return {"id":wid,"date":e.date,"weight":e.weight} diff --git a/docs/MEMBERSHIP_SYSTEM.md b/docs/MEMBERSHIP_SYSTEM.md new file mode 100644 index 0000000..b48b4fa --- /dev/null +++ b/docs/MEMBERSHIP_SYSTEM.md @@ -0,0 +1,1058 @@ +# Mitai Jinkendo - Membership & Subscription System (v9c) + +**Version:** v9c-dev +**Status:** Backend & Admin-UI komplett, Enforcement deaktiviert +**Letzte Aktualisierung:** 20. März 2026 + +--- + +## Inhaltsverzeichnis + +1. [Überblick](#überblick) +2. [Architektur-Entscheidungen](#architektur-entscheidungen) +3. [Datenbank-Schema](#datenbank-schema) +4. [Backend-API](#backend-api) +5. [Frontend-Komponenten](#frontend-komponenten) +6. [Feature-Enforcement-System](#feature-enforcement-system) +7. [Lessons Learned](#lessons-learned) +8. [Roadmap](#roadmap) + +--- + +## Überblick + +Das Mitai Jinkendo Membership-System (v9c) ist ein flexibles, tier-basiertes Subscription-System mit folgenden Kern-Features: + +### Implementierte Features ✅ + +- **4 Tier-Stufen**: free, basic, premium, selfhosted +- **Feature-Registry-Pattern**: Zentrale Definition aller limitierbaren Features +- **Flexible Limit-Matrix**: Admin kann Tier × Feature Limits konfigurieren +- **User-Override-System**: Individuelle Limits pro User +- **Coupon-System**: 3 Typen (single_use, multi_use_period, gift) +- **Coupon-Stacking**: Intelligente Pause/Resume-Logik bei temporären Zugriffen +- **Access-Grants**: Zeitlich begrenzte Tier-Zugriffe mit Quelle-Tracking +- **User-Activity-Log**: JSONB-basierte Aktivitätsverfolgung +- **Admin-UI**: Vollständige Verwaltungsoberfläche für alle Aspekte + +### Geplante Features 🔲 + +- Feature-Enforcement in Endpoints (needs redesign) +- Selbst-Registrierung mit E-Mail-Verifizierung +- Trial-System mit automatischem Downgrade +- Bonus-System (Login-Streaks) +- Stripe-Integration +- Partner-Integration (Wellpass, Hansefit) + +--- + +## Architektur-Entscheidungen + +### 1. Feature-Registry-Pattern + +**Entscheidung:** Alle limitierbaren Features werden zentral in einer `features` Tabelle definiert. + +**Rationale:** +- Neue Features können ohne Schema-Migration hinzugefügt werden +- Metadaten (name, description, category, unit) sind direkt verfügbar +- Konsistenz zwischen Backend-Checks und Frontend-Display +- Admin-UI kann automatisch generiert werden + +**Schema:** +```sql +CREATE TABLE features ( + id TEXT PRIMARY KEY, -- 'ai_calls', 'data_export', etc. + name TEXT NOT NULL, -- 'KI-Analysen', 'Daten exportieren' + description TEXT, + category TEXT, -- 'ai', 'export', 'data', 'integration' + limit_type TEXT DEFAULT 'count', -- 'count' oder 'boolean' + reset_period TEXT DEFAULT 'never', -- 'never', 'daily', 'monthly' + default_limit INTEGER, -- NULL = unbegrenzt + active BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP +); +``` + +**Beispiel-Features:** +```sql +-- Count-based mit monatlichem Reset +('ai_calls', 'KI-Analysen', 'KI-Auswertungen pro Monat', 'ai', 'count', 'monthly', 0, true) + +-- Boolean-Feature (an/aus) +('ai_pipeline', 'KI-Pipeline', 'Vollständige Pipeline-Analyse', 'ai', 'boolean', 'never', 0, true) + +-- Count-based ohne Reset (Gesamt-Limit) +('weight_entries', 'Gewichtseinträge', 'Anzahl Gewichtsmessungen', 'data', 'count', 'never', NULL, true) +``` + +--- + +### 2. Tier-System + +**Entscheidung:** Vereinfachte Tier-Tabelle ohne hart-codierte Limits. + +**Rationale:** +- Limits werden in separate `tier_limits` Tabelle ausgelagert +- Tiers können dynamisch hinzugefügt werden +- Pricing-Informationen zentral verwaltet +- Flexibilität für zukünftige Tier-Erweiterungen + +**Schema:** +```sql +CREATE TABLE tiers ( + id TEXT PRIMARY KEY, -- 'free', 'basic', 'premium', 'selfhosted' + slug TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, -- 'Free', 'Basic', 'Premium', 'Self-Hosted' + description TEXT, + price_monthly DECIMAL(10,2), + price_yearly DECIMAL(10,2), + sort_order INTEGER DEFAULT 0, + active BOOLEAN DEFAULT true, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP +); +``` + +**Initial Tiers:** +- **free**: Eingeschränkt (30 Daten-Einträge, 0 KI-Calls, kein Export) +- **basic**: Kernfunktionen (unbegrenzte Daten, 3 KI-Calls/Monat, Export erlaubt) +- **premium**: Alles unbegrenzt (inkl. KI-Pipeline, Connectoren) +- **selfhosted**: Admin-Tier für selbst-gehostete Installationen + +--- + +### 3. Zugriffs-Hierarchie + +**Entscheidung:** Drei-stufige Priorität für effektive Tier-Ermittlung. + +**Priorität (höchste zuerst):** +1. **Admin-Override**: `profiles.tier_locked = true` → nutzt `profiles.tier` +2. **Access-Grant**: Aktiver, nicht-pausierter Grant → nutzt `access_grants.granted_tier` +3. **Trial**: `profiles.trial_ends_at > NOW()` → nutzt trial tier +4. **Base Tier**: `profiles.tier` + +**Implementierung:** +```python +def get_effective_tier(profile_id: str) -> str: + """Get effective tier considering all overrides.""" + with get_db() as conn: + cur = get_cursor(conn) + + # 1. Check if tier is locked by admin + cur.execute("SELECT tier, tier_locked FROM profiles WHERE id = %s", (profile_id,)) + profile = cur.fetchone() + if profile['tier_locked']: + return profile['tier'] + + # 2. Check for active access grant + cur.execute(""" + SELECT granted_tier FROM access_grants + WHERE profile_id = %s + AND is_active = true + AND valid_from <= CURRENT_TIMESTAMP + AND (valid_until IS NULL OR valid_until > CURRENT_TIMESTAMP) + ORDER BY created DESC LIMIT 1 + """, (profile_id,)) + grant = cur.fetchone() + if grant: + return grant['granted_tier'] + + # 3. Check trial + cur.execute(""" + SELECT tier FROM profiles + WHERE id = %s AND trial_ends_at > CURRENT_TIMESTAMP + """, (profile_id,)) + trial = cur.fetchone() + if trial: + return 'premium' # or configurable trial tier + + # 4. Base tier + return profile['tier'] +``` + +**Rationale:** +- Admin kann User dauerhaft einem Tier zuweisen (Support-Fälle) +- Temporäre Zugriffe (Coupons, Wellpass) haben Vorrang vor Base-Tier +- Trial-Logik ist transparent und automatisch +- Base-Tier ist Fallback + +--- + +### 4. Coupon-System + +**Entscheidung:** 3 Coupon-Typen mit unterschiedlicher Stacking-Logik. + +**Typen:** + +#### 4.1 Single-Use Coupon +- **Verwendung:** Einmalig einlösbar (z.B. Geschenk-Coupon) +- **Verhalten:** Erstellt `access_grant` mit fester Laufzeit +- **Stacking:** Zeitlich sequenziell (startet nach Ablauf vorheriger Grants) +- **Beispiel:** "30 Tage Premium geschenkt" + +```sql +INSERT INTO coupons (code, type, grants_tier, duration_days, max_redemptions) VALUES +('FRIEND-GIFT-ABC', 'single_use', 'premium', 30, 1); +``` + +#### 4.2 Multi-Use Period Coupon +- **Verwendung:** Unbegrenzt einlösbar während Gültigkeitszeitraum +- **Verhalten:** Pausiert andere Grants, reaktiviert sie nach Ablauf +- **Stacking:** Override mit Pause/Resume +- **Beispiel:** "Wellpass-Monatszugang März 2026" + +```sql +INSERT INTO coupons (code, type, grants_tier, valid_from, valid_until, max_redemptions) VALUES +('WELLPASS-2026-03', 'multi_use_period', 'premium', '2026-03-01', '2026-03-31', NULL); +``` + +**Stacking-Logik:** +```python +# User hat Single-Use Grant (20 Tage verbleibend) +# User löst Wellpass-Coupon ein +# → Single-Use Grant wird pausiert (is_active=false, paused_by=wellpass_grant_id) +# → Nach Wellpass-Ablauf: Single-Use wird reaktiviert (noch 20 Tage) +``` + +#### 4.3 Gift Coupon +- **Verwendung:** Vom System generiert als Bonus +- **Verhalten:** Wie Single-Use, aber spezielles Tracking +- **Beispiel:** "Login-Streak Belohnung" + +--- + +### 5. User-Restrictions (Override-System) + +**Entscheidung:** User-spezifische Overrides haben höchste Priorität. + +**Schema:** +```sql +CREATE TABLE user_feature_restrictions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER, -- NULL = unbegrenzt, überschreibt Tier-Limit + enabled BOOLEAN DEFAULT true, + reason TEXT, -- Warum wurde Override gesetzt? + set_by UUID REFERENCES profiles(id), -- Welcher Admin? + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP, + UNIQUE(profile_id, feature_id) +); +``` + +**Anwendungsfälle:** +- Admin gewährt einzelnem User mehr/weniger Zugriff +- Support-Fall: User bekommt temporär mehr KI-Calls +- Beta-Tester: Zugriff auf experimentelle Features +- Problem-User: Einschränkung bestimmter Features + +**Beispiel:** +```sql +-- User bekommt 100 KI-Calls/Monat statt Tier-Standard +INSERT INTO user_feature_restrictions (profile_id, feature_id, limit_value, reason, set_by) +VALUES ('user-uuid', 'ai_calls', 100, 'Beta-Tester', 'admin-uuid'); +``` + +--- + +## Datenbank-Schema + +### Übersicht aller v9c Tabellen + +``` +v9c Subscription System (11 neue Tabellen): +├── app_settings - Globale App-Konfiguration +├── tiers - Tier-Definitionen (free/basic/premium/selfhosted) +├── features - Feature-Registry (zentrale Feature-Definition) +├── tier_limits - Tier × Feature Matrix (Limits pro Tier) +├── user_feature_restrictions - User-spezifische Overrides +├── user_feature_usage - Usage-Tracking (für reset_period) +├── coupons - Coupon-Verwaltung (3 Typen) +├── coupon_redemptions - Einlösungs-Historie +├── access_grants - Zeitlich begrenzte Zugriffe +├── user_activity_log - Aktivitäts-Tracking (JSONB) +└── user_stats - Aggregierte Statistiken + +Erweiterte Tabellen: +└── profiles - Neue Spalten: tier, tier_locked, trial_ends_at, + email_verified, invited_by, contract_type +``` + +### Detaillierte Schema-Definitionen + +#### app_settings +```sql +CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT, + value_type TEXT DEFAULT 'string', -- 'string', 'integer', 'boolean', 'json' + description TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_by UUID REFERENCES profiles(id) +); + +-- Beispiel-Einstellungen: +INSERT INTO app_settings (key, value, value_type, description) VALUES +('trial_days', '14', 'integer', 'Anzahl Tage Trial-Zugang'), +('trial_behavior', 'downgrade', 'string', 'downgrade|lock nach Trial-Ende'), +('allow_registration', 'false', 'boolean', 'Selbst-Registrierung erlaubt?'), +('default_tier_trial', 'premium', 'string', 'Tier während Trial'), +('gift_coupons_per_month', '3', 'integer', 'Max Geschenk-Coupons pro User/Monat'); +``` + +#### tier_limits (Kern der Matrix) +```sql +CREATE TABLE tier_limits ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tier_id TEXT NOT NULL REFERENCES tiers(id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features(id) ON DELETE CASCADE, + limit_value INTEGER, -- NULL = unbegrenzt, 0 = deaktiviert + enabled BOOLEAN DEFAULT true, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated TIMESTAMP, + UNIQUE(tier_id, feature_id) +); + +-- Beispiel Free Tier: +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES +('free', 'weight_entries', 30), +('free', 'ai_calls', 0), -- Deaktiviert +('free', 'data_export', 0); -- Deaktiviert + +-- Beispiel Premium Tier: +INSERT INTO tier_limits (tier_id, feature_id, limit_value) VALUES +('premium', 'weight_entries', NULL), -- Unbegrenzt +('premium', 'ai_calls', NULL), -- Unbegrenzt +('premium', 'ai_pipeline', 1); -- Boolean: Aktiviert +``` + +#### access_grants (Zeitliche Zugriffe) +```sql +CREATE TABLE access_grants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + granted_tier TEXT NOT NULL REFERENCES tiers(id), + valid_from TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMP, -- NULL = unbegrenzt + source TEXT, -- 'coupon', 'admin_grant', 'trial' + source_reference TEXT, -- Coupon-Code oder Admin-Notiz + is_active BOOLEAN DEFAULT true, -- Kann pausiert werden + paused_at TIMESTAMP, + paused_by UUID REFERENCES access_grants(id), -- Welcher Grant hat pausiert? + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES profiles(id) +); + +-- Index für Performance +CREATE INDEX idx_access_grants_active ON access_grants(profile_id, is_active, valid_until DESC); +``` + +#### user_activity_log +```sql +CREATE TABLE user_activity_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + activity_type TEXT NOT NULL, -- 'login', 'coupon_redeemed', 'tier_change', etc. + details JSONB, -- Flexible Details + ip_address TEXT, + user_agent TEXT, + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Index für Abfragen +CREATE INDEX idx_activity_log_profile ON user_activity_log(profile_id, created DESC); + +-- Beispiel-Einträge: +-- Login +INSERT INTO user_activity_log (profile_id, activity_type, details) VALUES +('uuid', 'login', '{"ip": "192.168.1.1", "device": "Chrome/Mac"}'); + +-- Coupon eingelöst +INSERT INTO user_activity_log (profile_id, activity_type, details) VALUES +('uuid', 'coupon_redeemed', '{"code": "FRIEND-GIFT-ABC", "tier": "premium", "days": 30}'); + +-- Tier-Änderung +INSERT INTO user_activity_log (profile_id, activity_type, details) VALUES +('uuid', 'tier_change', '{"from": "free", "to": "basic", "reason": "admin_grant"}'); +``` + +--- + +## Backend-API + +### Router-Übersicht + +``` +v9c Backend-Router (7 neue): +├── /api/subscription - User-facing: Abo-Status, Usage, Limits +├── /api/coupons - User: redeem; Admin: CRUD +├── /api/features - Admin: Feature-Registry CRUD + check-access Endpoint +├── /api/tiers - Admin: Tier-Verwaltung CRUD +├── /api/tier-limits - Admin: Matrix-Editor (Tier × Feature) +├── /api/user-restrictions- Admin: User-Override-System +└── /api/access-grants - Admin: Grant-Verwaltung (create, list, revoke) +``` + +### Wichtige Endpoints + +#### User-Facing + +**GET /api/subscription/me** +```json +{ + "tier": "basic", + "tier_source": "base", // 'base', 'trial', 'access_grant', 'admin_locked' + "trial_ends_at": null, + "access_grants": [ + { + "granted_tier": "premium", + "valid_until": "2026-04-15", + "source": "coupon", + "days_remaining": 25 + } + ] +} +``` + +**GET /api/subscription/usage** +```json +{ + "ai_calls": { + "limit": 3, + "used": 2, + "remaining": 1, + "reset_at": "2026-04-01T00:00:00" + }, + "data_export": { + "limit": 5, + "used": 0, + "remaining": 5, + "reset_at": "2026-04-01T00:00:00" + } +} +``` + +**POST /api/coupons/redeem** +```json +{ + "code": "FRIEND-GIFT-ABC" +} +``` +Response: +```json +{ + "success": true, + "granted_tier": "premium", + "valid_until": "2026-04-19", + "message": "30 Tage Premium-Zugang aktiviert!" +} +``` + +#### Admin-Only + +**GET /api/tier-limits** +```json +[ + { + "tier_id": "free", + "feature_id": "ai_calls", + "limit_value": 0, + "enabled": false + }, + { + "tier_id": "basic", + "feature_id": "ai_calls", + "limit_value": 3, + "enabled": true + } +] +``` + +**PUT /api/tier-limits** +```json +{ + "tier_id": "basic", + "feature_id": "ai_calls", + "limit_value": 5 +} +``` + +**POST /api/access-grants** +```json +{ + "profile_id": "user-uuid", + "granted_tier": "premium", + "valid_until": "2026-12-31", + "source": "admin_grant", + "source_reference": "Support-Fall #123" +} +``` + +--- + +## Frontend-Komponenten + +### Admin-UI (vollständig implementiert) + +#### 1. AdminFeaturesPage +**Route:** `/admin/features` + +**Funktionen:** +- Feature-Liste (sortierbar, filterbar) +- Neues Feature hinzufügen +- Feature bearbeiten (Name, Beschreibung, Limits, Reset-Period) +- Feature deaktivieren (soft-delete) + +**UI-Elemente:** +- Feature-Tabelle mit Spalten: Name, Kategorie, Limit-Typ, Reset-Period, Default-Limit +- Modal für Feature-Bearbeitung +- Kategorie-Filter (ai, export, data, integration) + +#### 2. AdminTiersPage +**Route:** `/admin/tiers` + +**Funktionen:** +- Tier-Liste mit CRUD +- Pricing (monatlich/jährlich) konfigurierbar +- Sort-Order für Anzeige-Reihenfolge +- Tier aktivieren/deaktivieren + +#### 3. AdminTierLimitsPage +**Route:** `/admin/tier-limits` + +**Funktionen:** +- **Matrix-Editor**: Tiers (Spalten) × Features (Zeilen) +- Responsive: Desktop = Tabelle, Mobile = Cards +- Inline-Editing mit Auto-Save +- Checkbox für Boolean-Features +- Number-Input für Count-Features +- NULL/∞ für unbegrenzt + +**UI-Konzept:** +``` +┌────────────────┬──────┬───────┬─────────┬────────────┐ +│ Feature │ Free │ Basic │ Premium │ Selfhosted │ +├────────────────┼──────┼───────┼─────────┼────────────┤ +│ Gewicht │ 30 │ ∞ │ ∞ │ ∞ │ +│ KI-Calls/Mon │ ☐ 0 │ ☑ 3 │ ☑ ∞ │ ☑ ∞ │ +│ KI-Pipeline │ ☐ │ ☐ │ ☑ │ ☑ │ +│ Export/Mon │ ☐ 0 │ ☑ 5 │ ☑ ∞ │ ☑ ∞ │ +└────────────────┴──────┴───────┴─────────┴────────────┘ +``` + +#### 4. AdminCouponsPage +**Route:** `/admin/coupons` + +**Funktionen:** +- Coupon-Liste (Code, Typ, Tier, Gültigkeit, Einlösungen) +- Neuer Coupon (3 Typen) +- Auto-Generate Code (Button) +- Redemption-Historie ansehen +- Coupon deaktivieren + +**Coupon-Typen-UI:** +``` +[ Single-Use ] [ Multi-Use Period ] [ Gift ] + +Single-Use: + Code: [FRIEND-GIFT-___] [Generate] + Tier: [Premium ▼] + Dauer: [30] Tage + Max Einlösungen: [1] + +Multi-Use Period: + Code: [WELLPASS-2026-__] [Generate] + Tier: [Premium ▼] + Gültig von: [2026-03-01] + Gültig bis: [2026-03-31] + Max Einlösungen: [∞] +``` + +#### 5. AdminUserRestrictionsPage +**Route:** `/admin/user-restrictions` + +**Funktionen:** +- User auswählen (Dropdown) +- Aktueller Tier anzeigen +- Feature-Liste mit: + - Tier-Limit (readonly, grau) + - Aktuelle Nutzung + - Override-Eingabe (leer = Tier-Standard) + - Save-Button pro Feature +- "Alle Overrides entfernen" Button + +**UI-Konzept:** +``` +User: [Lars Stommer ▼] Tier: selfhosted + +┌─────────────┬────────────┬─────────┬──────────┬────────┐ +│ Feature │ Tier-Limit │ Genutzt │ Override │ Aktion │ +├─────────────┼────────────┼─────────┼──────────┼────────┤ +│ KI-Calls │ ∞ │ 5/∞ │ [100] │ [Save] │ +│ Export │ ∞ │ 2/∞ │ [ ] │ [Save] │ +│ Gewicht │ ∞ │ 50/∞ │ [ ] │ [Save] │ +└─────────────┴────────────┴─────────┴──────────┴────────┘ + +[Alle Overrides entfernen] +``` + +#### 6. SubscriptionPage (User-facing) +**Route:** `/subscription` + +**Funktionen:** +- Aktueller Tier + Quelle +- Tier-Badge mit Icon +- Feature-Liste mit Limits +- Usage-Progress-Bars +- Coupon-Einlösung +- Access-Grant-Historie + +**UI-Konzept:** +``` +┌─────────────────────────────────────┐ +│ Dein Abo: [🟢 PREMIUM] │ +│ Quelle: Coupon (noch 25 Tage) │ +└─────────────────────────────────────┘ + +Features & Limits: +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +KI-Analysen 5/10 +[████████░░] 50% Reset: 1.4.2026 + +Daten-Export 2/5 +[████░░░░░░] 40% Reset: 1.4.2026 + +Gewichtseinträge 120/∞ +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Coupon einlösen: +[_________________] [Einlösen] +``` + +--- + +## Feature-Enforcement-System + +### Status: ⚠️ DEAKTIVIERT (Rollback 20.03.2026) + +**Versuch-Implementation:** Commits 3745ebd, cbad50a, cd4d912, 8415509 +**Rollback:** Commit 4fcde4a + +### Was war geplant + +**Backend:** +```python +# In jedem limitierten Endpoint +def some_endpoint(session = Depends(require_auth)): + pid = session['profile_id'] + + # 1. Feature-Check + access = check_feature_access(pid, 'feature_slug') + if not access['allowed']: + if access['reason'] == 'feature_disabled': + raise HTTPException(403, "Feature nicht verfügbar") + elif access['reason'] == 'limit_exceeded': + raise HTTPException(429, f"Limit erreicht ({access['limit']})") + + # 2. Funktion ausführen + result = do_something() + + # 3. Usage inkrementieren + increment_feature_usage(pid, 'feature_slug') + + return result +``` + +**Frontend:** +```jsx +import { FeatureGate, FeatureBadge } from '../components/FeatureGate' + +function MyComponent() { + return ( + + + + ) +} +``` + +### Was schief ging + +1. **Frontend-Cache-Problem** + - FeatureGate cached Feature-Status + - Änderungen im Admin-Panel nicht sofort sichtbar + - Optimistic rendering zeigte Features kurz an bevor Block + +2. **Backend-Inkonsistenzen** + - Feature-IDs stimmten nicht mit DB überein (data_export vs export_csv) + - Manche Features fehlten komplett (csv_import) + - increment_feature_usage() hatte silent failures + +3. **Analyse-History zerstört** + - DELETE vor INSERT entfernt, aber das war GEWOLLT im Original + - Führte zu "keine Historisierung"-Beschwerde + - War Missverständnis der Original-Logik + +4. **Export-Buttons verschwunden** + - FeatureGate blockierte sofort + - Migration nicht gelaufen → Features existierten nicht + - canExport-Flag wurde überschrieben + +5. **Pipeline-Duplikate** + - Filter ließ 'pipeline' Prompt durch + - Scope-Bug: speicherte als 'gesamt' statt 'pipeline' + +### Lessons Learned + +1. **Nie ohne vollständiges Verständnis refactorn** + - DELETE-Logik war absichtlich (1 Analyse pro Scope) + - User wollte aber History → Requirements unklar + +2. **Migrations müssen atomar laufen** + - Features müssen VOR Enforcement existieren + - Auto-Migration muss zuverlässig sein + - Test-Daten für lokale Entwicklung + +3. **Frontend-Gates brauchen Refresh-Mechanismus** + - WebSocket oder Polling nach Admin-Änderungen + - Oder: "Neu laden" Button prominent anzeigen + - Optimistic rendering ist riskant + +4. **Feature-IDs konsolidieren** + - Ein Feature für Export, nicht drei (csv/json/zip) + - Konsistent zwischen DB, Backend-Code und Frontend + +5. **Inkrementelle Einführung** + - Erst Backend-Checks als Logs (nicht blockierend) + - Dann Frontend-Display (aber funktional) + - Dann Enforcement aktivieren nach Tests + +### Nächste Schritte für Re-Implementation + +1. **Phase 1: Cleanup** + - Feature-Definitionen konsolidieren + - Migration auf Idempotenz prüfen + - Test-Daten-Script erstellen + +2. **Phase 2: Backend Non-Blocking** + - Feature-Checks in Endpoints einbauen + - Aber nur loggen, nicht blockieren + - Monitoring: Wie oft würde blockiert? + +3. **Phase 3: Frontend Display** + - Usage-Counter anzeigen (ohne Gates) + - Admin kann sehen was genutzt wird + - Validierung gegen tatsächliche API-Calls + +4. **Phase 4: Enforcement (opt-in)** + - Per Feature-Flag aktivierbar + - Erst für Admin-Accounts testen + - Dann für Test-User + - Dann Rollout + +--- + +## Roadmap + +### v9c - Fertigstellung (Q2 2026) + +**Prio 1: Feature-Enforcement (Redesign)** +- ✅ Backend-Checks als Log-Only implementieren +- ✅ Frontend Usage-Display ohne Gates +- ✅ Feature-ID Konsolidierung +- ✅ Test-Suite für alle Limits +- ⏳ Opt-in Enforcement per Feature-Flag +- ⏳ Rollout-Plan mit Rollback-Option + +**Prio 2: Registrierung & Trial** +- Self-Registration mit E-Mail-Verifizierung +- Automatischer Trial-Start (14 Tage Premium) +- Trial-Countdown-Banner +- Auto-Downgrade nach Trial-Ende + +**Prio 3: App-Settings UI** +- Admin-Panel für globale Konfiguration +- Trial-Einstellungen (Dauer, Verhalten) +- Registrierungs-Toggle +- E-Mail-Template-Editor + +### v9d - Monetarisierung (Q3 2026) + +**Prio 1: Stripe-Integration** +- Self-Service Upgrade (Premium) +- Subscription-Management +- Webhook-Handler +- Rechnungs-E-Mails + +**Prio 2: Bonus-System** +- Login-Streak-Tracking +- Punkte-System +- Geschenk-Coupons (automatisch) +- Achievements + +**Prio 3: Fitness-Connectoren** +- OAuth2-Framework +- Strava-Connector +- Withings-Connector (Waage) +- Garmin-Connector + +### v9e - Partner & Enterprise (Q4 2026) + +**Prio 1: Partner-Integration** +- Wellpass-Authentifizierung +- Hansefit-Authentifizierung +- Partner-Admin-UI +- Usage-Reporting für Partner + +**Prio 2: Enterprise Features** +- Multi-Tenant-Support +- White-Label-Option +- SAML/SSO +- API-Keys für Drittanbieter + +--- + +## Technische Details + +### Performance-Überlegungen + +**check_feature_access() Caching:** +- Cache auf Request-Level (nicht global) +- Verhindert multiple DB-Calls pro Request +- TTL: 5 Minuten für Frontend-Checks + +**Database Indices:** +```sql +-- Kritische Indices für Performance +CREATE INDEX idx_tier_limits_lookup ON tier_limits(tier_id, feature_id); +CREATE INDEX idx_user_restrictions_lookup ON user_feature_restrictions(profile_id, feature_id); +CREATE INDEX idx_feature_usage_lookup ON user_feature_usage(profile_id, feature_id, reset_at); +CREATE INDEX idx_access_grants_active ON access_grants(profile_id, is_active, valid_until DESC); +``` + +### Security-Considerations + +**Coupon-Code-Generation:** +- Kryptografisch sicherer Zufallsgenerator +- 12 Zeichen: XXXXX-YYYYY-ZZ +- Kollisions-Check vor INSERT + +**Access-Grant-Validation:** +- Zeitstempel-Checks auf DB-Ebene (PostgreSQL TIMESTAMP) +- Keine Client-side Validierung für Enforcement +- is_active Flag für sofortigen Widerruf + +**User-Restrictions:** +- Nur Admins können setzen +- Audit-Log (set_by, reason) +- Kann nicht via User-API manipuliert werden + +--- + +## Testing-Strategie + +### Unit-Tests (Backend) + +```python +def test_check_feature_access_user_override(): + # Setup: User mit Free-Tier + Override für ai_calls=100 + profile_id = create_test_profile(tier='free') + set_user_restriction(profile_id, 'ai_calls', 100) + + # Test: Override hat Vorrang vor Tier-Limit + access = check_feature_access(profile_id, 'ai_calls') + assert access['allowed'] == True + assert access['limit'] == 100 + +def test_coupon_stacking_pause_resume(): + # Setup: Single-Use Grant aktiv + profile_id = create_test_profile() + grant1 = create_access_grant(profile_id, 'premium', days=30) + + # Test: Multi-Use Coupon pausiert Single-Use + redeem_coupon(profile_id, 'WELLPASS-CODE') + grant1_reloaded = get_access_grant(grant1.id) + assert grant1_reloaded['is_active'] == False + + # Test: Nach Wellpass-Ablauf wird Single-Use reaktiviert + expire_wellpass_grant(profile_id) + grant1_reloaded = get_access_grant(grant1.id) + assert grant1_reloaded['is_active'] == True +``` + +### Integration-Tests (API) + +```bash +# Scenario: Free User versucht KI-Analyse +curl -X POST /api/insights/run/gesamt \ + -H "X-Auth-Token: $TOKEN" \ + -H "X-Profile-Id: $FREE_USER_ID" + +# Expected: HTTP 403 "KI nicht verfügbar" + +# Scenario: User löst Coupon ein +curl -X POST /api/coupons/redeem \ + -H "X-Auth-Token: $TOKEN" \ + -d '{"code": "FRIEND-GIFT-ABC"}' + +# Expected: HTTP 200 + Access-Grant erstellt + +# Scenario: User mit Premium-Grant kann KI nutzen +curl -X POST /api/insights/run/gesamt \ + -H "X-Auth-Token: $TOKEN" \ + -H "X-Profile-Id: $FREE_USER_ID" + +# Expected: HTTP 200 + Analyse-Ergebnis +``` + +--- + +## Deployment-Notes + +### Migration-Reihenfolge + +```bash +# 1. Backup +pg_dump mitai_prod > backup_before_v9c.sql + +# 2. v9c Schema-Migration +psql mitai_prod < backend/migrations/v9c_subscription_system.sql + +# 3. Feature-Fixes (falls nötig) +psql mitai_prod < backend/migrations/v9c_fix_features.sql + +# 4. Backend neu starten (Auto-Migration läuft) +docker compose restart backend + +# 5. Verifizieren +docker logs mitai-api | grep "v9c Migration" +# Expected: "✅ Migration completed successfully!" +``` + +### Rollback-Plan + +```sql +-- Emergency Rollback v9c +DROP TABLE IF EXISTS user_stats CASCADE; +DROP TABLE IF EXISTS user_activity_log CASCADE; +DROP TABLE IF EXISTS coupon_redemptions CASCADE; +DROP TABLE IF EXISTS coupons CASCADE; +DROP TABLE IF EXISTS access_grants CASCADE; +DROP TABLE IF EXISTS user_feature_usage CASCADE; +DROP TABLE IF EXISTS user_feature_restrictions CASCADE; +DROP TABLE IF EXISTS tier_limits CASCADE; +DROP TABLE IF EXISTS features CASCADE; +DROP TABLE IF EXISTS tiers CASCADE; +DROP TABLE IF EXISTS app_settings CASCADE; + +-- Profiles-Spalten entfernen +ALTER TABLE profiles + DROP COLUMN tier, + DROP COLUMN tier_locked, + DROP COLUMN trial_ends_at, + DROP COLUMN email_verified, + DROP COLUMN email_verify_token, + DROP COLUMN invited_by, + DROP COLUMN contract_type, + DROP COLUMN contract_valid_until, + DROP COLUMN stripe_customer_id; +``` + +--- + +## Support & Troubleshooting + +### Häufige Probleme + +**Problem: User kann Feature nicht nutzen** +```sql +-- Diagnose: Effektiven Tier prüfen +SELECT tier, tier_locked, trial_ends_at FROM profiles WHERE id = 'user-uuid'; + +-- Diagnose: Access-Grants prüfen +SELECT * FROM access_grants +WHERE profile_id = 'user-uuid' + AND is_active = true +ORDER BY created DESC; + +-- Diagnose: Feature-Limit prüfen +SELECT tl.* FROM tier_limits tl +WHERE tier_id = (SELECT tier FROM profiles WHERE id = 'user-uuid') + AND feature_id = 'ai_calls'; + +-- Diagnose: User-Override prüfen +SELECT * FROM user_feature_restrictions +WHERE profile_id = 'user-uuid' AND feature_id = 'ai_calls'; +``` + +**Problem: Coupon lässt sich nicht einlösen** +```sql +-- Diagnose: Coupon gültig? +SELECT * FROM coupons WHERE code = 'COUPON-CODE'; + +-- Check: Bereits eingelöst? +SELECT * FROM coupon_redemptions +WHERE coupon_id = (SELECT id FROM coupons WHERE code = 'COUPON-CODE') + AND profile_id = 'user-uuid'; + +-- Check: Max Einlösungen erreicht? +SELECT c.max_redemptions, COUNT(cr.*) as current_redemptions +FROM coupons c +LEFT JOIN coupon_redemptions cr ON cr.coupon_id = c.id +WHERE c.code = 'COUPON-CODE' +GROUP BY c.id, c.max_redemptions; +``` + +### Admin-Tools + +**User-Tier manuell ändern:** +```sql +-- Tier setzen und locken +UPDATE profiles SET tier = 'premium', tier_locked = true WHERE id = 'user-uuid'; + +-- Access-Grant manuell erstellen +INSERT INTO access_grants (profile_id, granted_tier, valid_until, source, source_reference) +VALUES ('user-uuid', 'premium', '2026-12-31', 'admin_grant', 'Support-Fall #123'); +``` + +**Feature-Usage zurücksetzen:** +```sql +-- Alle Usage für User zurücksetzen +DELETE FROM user_feature_usage WHERE profile_id = 'user-uuid'; + +-- Nur bestimmtes Feature zurücksetzen +DELETE FROM user_feature_usage +WHERE profile_id = 'user-uuid' AND feature_id = 'ai_calls'; +``` + +--- + +## Anhang + +### Glossar + +- **Tier**: Subscription-Stufe (free, basic, premium, selfhosted) +- **Feature**: Limitierbare Funktionalität (ai_calls, data_export, etc.) +- **Limit**: Maximale Anzahl Nutzungen (count) oder an/aus (boolean) +- **Access-Grant**: Zeitlich begrenzte Tier-Berechtigung +- **Coupon**: Einlösbarer Code für Tier-Zugang +- **Override**: User-spezifische Abweichung vom Tier-Limit +- **Reset-Period**: Zeitraum nach dem Limit zurückgesetzt wird (never, daily, monthly) + +### Kontakt & Fragen + +- **Repository**: http://192.168.2.144:3000/Lars/mitai-jinkendo +- **Dokumentation**: `/docs/` im Repository +- **Issues**: Gitea Issues oder direkt an Lars + +--- + +**Letzte Aktualisierung:** 20. März 2026 +**Autor:** Lars Stommer + Claude Opus 4.6 +**Version:** v9c-dev diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..75c2000 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6766 @@ +{ + "name": "bodytrack", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bodytrack", + "version": "1.0.0", + "dependencies": { + "dayjs": "^1.11.11", + "jspdf": "^2.5.1", + "jspdf-autotable": "^3.8.2", + "lucide-react": "^0.383.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.2.12", + "vite-plugin-pwa": "^0.20.0" + } + }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.6.tgz", + "integrity": "sha512-P+ZygBLZtkp0qqOAJJVX4oX/sFo5JR3eBWwwuqHHhK0GIgQOKWrAfiAaWX0aArHkRWHMuggFEgAZNxVPwPZYaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema": "^0.4.0", + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", + "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-0.4.4.tgz", + "integrity": "sha512-XHeJC5Bgvs8LfukDwWZp7yeqin6ns8RTl2B9avbejt6tZqsqvVoWI7ZTQrcNsfKEDWBTnTxM8nMDkO2IFFbd0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^6.0.1", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@surma/rollup-plugin-off-main-thread": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", + "integrity": "sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.6", + "json5": "^2.2.0", + "magic-string": "^0.25.0", + "string.prototype.matchall": "^4.0.6" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz", + "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001780", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz", + "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz", + "integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/jspdf-autotable": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/jspdf-autotable/-/jspdf-autotable-3.8.4.tgz", + "integrity": "sha512-rSffGoBsJYX83iTRv8Ft7FhqfgEL2nLpGAIiqruEQQ3e4r0qdLFbPUB7N9HAle0I3XgpisvyW751VHCqKUVOgQ==", + "license": "MIT", + "peerDependencies": { + "jspdf": "^2.5.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.383.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.383.0.tgz", + "integrity": "sha512-13xlG0CQCJtzjSQYwwJ3WRqMHtRj3EXmLlorrARt7y+IHnxUCp3XyFNL1DfaGySWxHObDvnu1u1dV+0VMKHUSg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz", + "integrity": "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.0.tgz", + "integrity": "sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/smob": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.1.tgz", + "integrity": "sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sourcemap-codec": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz", + "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==", + "deprecated": "Please use @jridgewell/sourcemap-codec instead", + "dev": true, + "license": "MIT" + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-pwa": { + "version": "0.20.5", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.20.5.tgz", + "integrity": "sha512-aweuI/6G6n4C5Inn0vwHumElU/UEpNuO+9iZzwPZGTCH87TeZ6YFMrEY6ZUBQdIHHlhTsbMDryFARcSuOdsz9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.0", + "workbox-build": "^7.1.0", + "workbox-window": "^7.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^0.2.6", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0", + "workbox-build": "^7.1.0", + "workbox-window": "^7.1.0" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.0.tgz", + "integrity": "sha512-8CB9OxKAgKZKyNMwfGZ1XESx89GryWTfI+V5yEj8sHjFH8MFelUwYXEyldEK6M6oKMmn807GoJFUEA1sC4XS9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.0.tgz", + "integrity": "sha512-+eZQwoktlvo62cI0b+QBr40v5XjighxPq3Fzo9AWMiAosmpG5gxRHgTbGGhaJv/q/MFVxwFNGh/UwHZ/8K88lA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-build": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.0.tgz", + "integrity": "sha512-Ntk1pWb0caOFIvwz/hfgrov/OJ45wPEhI5PbTywQcYjyZiVhT3UrwwUPl6TRYbTm4moaFYithYnl1lvZ8UjxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^5.2.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-replace": "^2.4.1", + "@rollup/plugin-terser": "^0.4.3", + "@surma/rollup-plugin-off-main-thread": "^2.2.3", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "lodash": "^4.17.20", + "pretty-bytes": "^5.3.0", + "rollup": "^2.79.2", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.0", + "workbox-broadcast-update": "7.4.0", + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-google-analytics": "7.4.0", + "workbox-navigation-preload": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-range-requests": "7.4.0", + "workbox-recipes": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0", + "workbox-streams": "7.4.0", + "workbox-sw": "7.4.0", + "workbox-window": "7.4.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-babel": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", + "integrity": "sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.10.4", + "@rollup/pluginutils": "^3.1.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + } + } + }, + "node_modules/workbox-build/node_modules/@rollup/plugin-replace": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-2.4.2.tgz", + "integrity": "sha512-IGcu+cydlUMZ5En85jxHH4qj2hta/11BHq95iHEyb2sbgiN0eCdzvUcHw5gt9pBL5lTi4JDYJ1acCoMGpTvEZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^3.1.0", + "magic-string": "^0.25.7" + }, + "peerDependencies": { + "rollup": "^1.20.0 || ^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@rollup/pluginutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", + "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "0.0.39", + "estree-walker": "^1.0.1", + "picomatch": "^2.2.2" + }, + "engines": { + "node": ">= 8.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0" + } + }, + "node_modules/workbox-build/node_modules/@types/estree": { + "version": "0.0.39", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", + "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/estree-walker": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", + "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-build/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-build/node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.0.tgz", + "integrity": "sha512-0Fb8795zg/x23ISFkAc7lbWes6vbw34DGFIMw31cwuHPgDEC/5EYm6m/ZkylLX0EnEbbOyOCLjKgFS/Z5g0HeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-core": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.0.tgz", + "integrity": "sha512-6BMfd8tYEnN4baG4emG9U0hdXM4gGuDU3ectXuVHnj71vwxTFI7WOpQJC4siTOlVtGqCUtj0ZQNsrvi6kZZTAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.0.tgz", + "integrity": "sha512-V50p4BxYhtA80eOvulu8xVfPBgZbkxJ1Jr8UUn0rvqjGhLDqKNtfrDfjJKnLz2U8fO2xGQJTx/SKXNTzHOjnHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.0.tgz", + "integrity": "sha512-MVPXQslRF6YHkzGoFw1A4GIB8GrKym/A5+jYDUSL+AeJw4ytQGrozYdiZqUW1TPQHW8isBCBtyFJergUXyNoWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.0", + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.0.tgz", + "integrity": "sha512-etzftSgdQfjMcfPgbfaZCfM2QuR1P+4o8uCA2s4rf3chtKTq/Om7g/qvEOcZkG6v7JZOSOxVYQiOu6PbAZgU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.0.tgz", + "integrity": "sha512-VQs37T6jDqf1rTxUJZXRl3yjZMf5JX/vDPhmx2CPgDDKXATzEoqyRqhYnRoxl6Kr0rqaQlp32i9rtG5zTzIlNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.0.tgz", + "integrity": "sha512-3Vq854ZNuP6Y0KZOQWLaLC9FfM7ZaE+iuQl4VhADXybwzr4z/sMmnLgTeUZLq5PaDlcJBxYXQ3U91V7dwAIfvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.0.tgz", + "integrity": "sha512-kOkWvsAn4H8GvAkwfJTbwINdv4voFoiE9hbezgB1sb/0NLyTG4rE7l6LvS8lLk5QIRIto+DjXLuAuG3Vmt3cxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.0", + "workbox-core": "7.4.0", + "workbox-expiration": "7.4.0", + "workbox-precaching": "7.4.0", + "workbox-routing": "7.4.0", + "workbox-strategies": "7.4.0" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.0.tgz", + "integrity": "sha512-C/ooj5uBWYAhAqwmU8HYQJdOjjDKBp9MzTQ+otpMmd+q0eF59K+NuXUek34wbL0RFrIXe/KKT+tUWcZcBqxbHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.0.tgz", + "integrity": "sha512-T4hVqIi5A4mHi92+5EppMX3cLaVywDp8nsyUgJhOZxcfSV/eQofcOA6/EMo5rnTNmNTpw0rUgjAI6LaVullPpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.0.tgz", + "integrity": "sha512-QHPBQrey7hQbnTs5GrEVoWz7RhHJXnPT+12qqWM378orDMo5VMJLCkCM1cnCk+8Eq92lccx/VgRZ7WAzZWbSLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.0", + "workbox-routing": "7.4.0" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.0.tgz", + "integrity": "sha512-ltU+Kr3qWR6BtbdlMnCjobZKzeV1hN+S6UvDywBrwM19TTyqA03X66dzw1tEIdJvQ4lYKkBFox6IAEhoSEZ8Xw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.0.tgz", + "integrity": "sha512-/bIYdBLAVsNR3v7gYGaV4pQW3M3kEPx5E8vDxGvxo6khTrGtSSCS7QiFKv9ogzBgZiy0OXLP9zO28U/1nF1mfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7595d67..6b9421f 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -20,6 +20,12 @@ import ActivityPage from './pages/ActivityPage' import Analysis from './pages/Analysis' import SettingsPage from './pages/SettingsPage' import GuidePage from './pages/GuidePage' +import AdminTierLimitsPage from './pages/AdminTierLimitsPage' +import AdminFeaturesPage from './pages/AdminFeaturesPage' +import AdminTiersPage from './pages/AdminTiersPage' +import AdminCouponsPage from './pages/AdminCouponsPage' +import AdminUserRestrictionsPage from './pages/AdminUserRestrictionsPage' +import SubscriptionPage from './pages/SubscriptionPage' import './app.css' function Nav() { @@ -115,6 +121,12 @@ function AppShell() { }/> }/> }/> + }/> + }/> + }/> + }/> + }/> + }/>
- setPerms(p=>({...p,ai_enabled:v?1:0}))} label="KI-Analysen erlaubt"/> - {!!perms.ai_enabled && ( -
- - setPerms(p=>({...p,ai_limit_day:e.target.value}))}/> - /Tag -
- )} - setPerms(p=>({...p,export_enabled:v?1:0}))} label="Daten-Export erlaubt"/> - - + {/* Feature-Overrides */} +
+ Feature-Limits: Nutze die neue{' '} + + User Feature-Overrides + {' '} + Seite um individuelle Limits zu setzen. +
{/* Email */}
@@ -397,6 +387,43 @@ export default function AdminPanel() { {/* Email Settings */} + + {/* v9c Subscription Management */} +
+
+ Subscription-System (v9c) +
+
+ Verwalte Tiers, Features und Limits für das neue Freemium-System. +
+
+ + + + + + + + + + + + + + + +
+
) } diff --git a/frontend/src/pages/AdminTierLimitsPage.jsx b/frontend/src/pages/AdminTierLimitsPage.jsx new file mode 100644 index 0000000..801d1e6 --- /dev/null +++ b/frontend/src/pages/AdminTierLimitsPage.jsx @@ -0,0 +1,499 @@ +import { useState, useEffect } from 'react' +import { Save, RotateCcw, ChevronDown, ChevronUp } from 'lucide-react' +import { api } from '../utils/api' + +export default function AdminTierLimitsPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [matrix, setMatrix] = useState({ tiers: [], features: [], limits: {} }) + const [changes, setChanges] = useState({}) + const [saving, setSaving] = useState(false) + const [isMobile, setIsMobile] = useState(window.innerWidth < 768) + + useEffect(() => { + loadMatrix() + const handleResize = () => setIsMobile(window.innerWidth < 768) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + async function loadMatrix() { + try { + setLoading(true) + const data = await api.getTierLimitsMatrix() + setMatrix(data) + setChanges({}) + setError('') + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + function handleChange(tierId, featureId, value) { + const key = `${tierId}:${featureId}` + const newChanges = { ...changes } + + // Allow temporary empty input for better UX + if (value === '') { + newChanges[key] = { tierId, featureId, value: '', tempValue: '' } + setChanges(newChanges) + return + } + + // Parse value + let parsedValue = null + if (value === 'unlimited' || value === '∞') { + parsedValue = null // unlimited + } else if (value === '0' || value === 'disabled') { + parsedValue = 0 // disabled + } else { + const num = parseInt(value) + if (!isNaN(num) && num >= 0) { + parsedValue = num + } else { + return // invalid input, ignore + } + } + + newChanges[key] = { tierId, featureId, value: parsedValue, tempValue: value } + setChanges(newChanges) + } + + async function saveChanges() { + // Filter out empty temporary values + const validChanges = Object.values(changes).filter(c => c.value !== '') + + if (validChanges.length === 0) { + setSuccess('Keine Änderungen') + return + } + + try { + setSaving(true) + setError('') + setSuccess('') + + const updates = validChanges.map(c => ({ + tier_id: c.tierId, + feature_id: c.featureId, + limit_value: c.value + })) + + await api.updateTierLimitsBatch(updates) + setSuccess(`${updates.length} Limits gespeichert`) + await loadMatrix() + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + function getCurrentValue(tierId, featureId) { + const key = `${tierId}:${featureId}` + if (key in changes) { + // Return temp value for display + return changes[key].tempValue !== undefined ? changes[key].tempValue : changes[key].value + } + return matrix.limits[key] ?? null + } + + function formatValue(val) { + if (val === '' || val === null || val === undefined) return '' + if (val === '∞' || val === 'unlimited') return '∞' + if (val === 0 || val === '0') return '0' + return val.toString() + } + + function groupFeaturesByCategory() { + const groups = {} + matrix.features.forEach(f => { + if (!groups[f.category]) groups[f.category] = [] + groups[f.category].push(f) + }) + return groups + } + + if (loading) return ( +
+
+
+ ) + + const hasChanges = Object.keys(changes).filter(k => changes[k].value !== '').length > 0 + const categoryGroups = groupFeaturesByCategory() + const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' } + const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' } + + // Mobile: Card-based view + if (isMobile) { + return ( +
+ {/* Header */} +
+
+ Tier Limits +
+
+ Limits pro Tier konfigurieren +
+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Mobile: Feature Cards */} + {Object.entries(categoryGroups).map(([category, features]) => ( +
+ {/* Category Header */} +
+ {categoryIcons[category]} {categoryNames[category] || category} +
+ + {/* Features */} + {features.map(feature => ( + + ))} +
+ ))} + + {/* Fixed Bottom Bar */} +
+ {hasChanges && ( + + )} + +
+
+ ) + } + + // Desktop: Table view + return ( +
+ {/* Header */} +
+
+
+ Tier Limits Matrix +
+
+ Feature-Limits pro Tier (leer = unbegrenzt, 0 = deaktiviert) +
+
+
+ {hasChanges && ( + + )} + +
+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Matrix Table */} +
+ + + + + {matrix.tiers.map(tier => ( + + ))} + + + + {Object.entries(categoryGroups).map(([category, features]) => ( + <> + {/* Category Header */} + + + + + {/* Feature Rows */} + {features.map((feature, idx) => ( + + + {matrix.tiers.map(tier => { + const currentValue = getCurrentValue(tier.id, feature.id) + const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== '' + + // Boolean features: Toggle button + if (feature.limit_type === 'boolean') { + const isEnabled = currentValue !== 0 && currentValue !== '0' + return ( + + ) + } + + // Count features: Text input + return ( + + ) + })} + + ))} + + ))} + +
+ Feature + +
{tier.name}
+
+ {tier.id} +
+
+ {categoryIcons[category]} {categoryNames[category] || category} +
+
{feature.name}
+
+ {feature.limit_type === 'boolean' ? '(ja/nein)' : `(count, reset: ${feature.reset_period})`} +
+
+ + + handleChange(tier.id, feature.id, e.target.value)} + placeholder="∞" + style={{ + width: '80px', + padding: '6px 8px', + border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 6, + textAlign: 'center', + fontSize: 13, + fontWeight: isChanged ? 600 : 400, + background: 'var(--bg)', + color: currentValue === 0 || currentValue === '0' ? 'var(--danger)' : + currentValue === null || currentValue === '' || currentValue === '∞' ? 'var(--accent)' : 'var(--text1)' + }} + /> +
+
+ + {/* Legend */} +
+ Eingabe: +
+ leer oder ∞ = Unbegrenzt + 0 = Deaktiviert + 1-999999 = Limit-Wert +
+
+
+ ) +} + +// Mobile Card Component +function FeatureMobileCard({ feature, tiers, getCurrentValue, handleChange, changes }) { + const [expanded, setExpanded] = useState(false) + + return ( +
+ {/* Feature Header */} +
setExpanded(!expanded)} + style={{ + display: 'flex', alignItems: 'center', justifyContent: 'space-between', + cursor: 'pointer', padding: '4px 0' + }} + > +
+
{feature.name}
+
+ {feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`} +
+
+ {expanded ? : } +
+ + {/* Tier Inputs (Expanded) */} + {expanded && ( +
+ {tiers.map(tier => { + const currentValue = getCurrentValue(tier.id, feature.id) + const isChanged = `${tier.id}:${feature.id}` in changes && changes[`${tier.id}:${feature.id}`].value !== '' + + // Boolean features: Toggle button + if (feature.limit_type === 'boolean') { + const isEnabled = currentValue !== 0 && currentValue !== '0' + return ( +
+ + +
+ ) + } + + // Count features: Text input + return ( +
+ + handleChange(tier.id, feature.id, e.target.value)} + placeholder="∞" + style={{ + border: `1.5px solid ${isChanged ? 'var(--accent)' : 'var(--border)'}`, + background: isChanged ? 'var(--accent-light)' : 'var(--bg)', + color: currentValue === 0 ? 'var(--danger)' : + currentValue === null || currentValue === '' ? 'var(--accent)' : 'var(--text1)', + fontWeight: isChanged ? 600 : 400 + }} + /> + + {currentValue === null || currentValue === '' ? '∞' : currentValue === 0 ? '❌' : '✓'} + +
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/AdminTiersPage.jsx b/frontend/src/pages/AdminTiersPage.jsx new file mode 100644 index 0000000..f29078d --- /dev/null +++ b/frontend/src/pages/AdminTiersPage.jsx @@ -0,0 +1,392 @@ +import { useState, useEffect } from 'react' +import { Save, Plus, Edit2, Trash2, X } from 'lucide-react' +import { api } from '../utils/api' + +export default function AdminTiersPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [tiers, setTiers] = useState([]) + const [editingId, setEditingId] = useState(null) + const [showAddForm, setShowAddForm] = useState(false) + const [formData, setFormData] = useState({ + id: '', + name: '', + description: '', + price_monthly_cents: '', + price_yearly_cents: '', + sort_order: 50, + active: true + }) + + useEffect(() => { + loadTiers() + }, []) + + async function loadTiers() { + try { + setLoading(true) + const data = await api.listTiers() + setTiers(data) + setError('') + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + function resetForm() { + setFormData({ + id: '', + name: '', + description: '', + price_monthly_cents: '', + price_yearly_cents: '', + sort_order: 50, + active: true + }) + setEditingId(null) + setShowAddForm(false) + } + + function startEdit(tier) { + setFormData({ + id: tier.id, + name: tier.name, + description: tier.description || '', + price_monthly_cents: tier.price_monthly_cents === null ? '' : tier.price_monthly_cents, + price_yearly_cents: tier.price_yearly_cents === null ? '' : tier.price_yearly_cents, + sort_order: tier.sort_order || 50, + active: tier.active + }) + setEditingId(tier.id) + setShowAddForm(false) + } + + async function handleSave() { + try { + setError('') + setSuccess('') + + // Validation + if (!formData.name.trim()) { + setError('Name erforderlich') + return + } + + const payload = { + name: formData.name.trim(), + description: formData.description.trim(), + price_monthly_cents: formData.price_monthly_cents === '' ? null : parseInt(formData.price_monthly_cents), + price_yearly_cents: formData.price_yearly_cents === '' ? null : parseInt(formData.price_yearly_cents), + sort_order: formData.sort_order, + active: formData.active + } + + if (editingId) { + // Update existing + await api.updateTier(editingId, payload) + setSuccess('Tier aktualisiert') + } else { + // Create new + if (!formData.id.trim()) { + setError('ID erforderlich') + return + } + payload.id = formData.id.trim() + await api.createTier(payload) + setSuccess('Tier erstellt') + } + + await loadTiers() + resetForm() + } catch (e) { + setError(e.message) + } + } + + async function handleDelete(tierId) { + if (!confirm('Tier wirklich deaktivieren?')) return + try { + setError('') + await api.deleteTier(tierId) + setSuccess('Tier deaktiviert') + await loadTiers() + } catch (e) { + setError(e.message) + } + } + + function formatPrice(cents) { + if (cents === null || cents === undefined) return 'Kostenlos' + return `${(cents / 100).toFixed(2)} €` + } + + if (loading) return ( +
+
+
+ ) + + return ( +
+ {/* Header */} +
+
+
+ Tier-Verwaltung +
+
+ Subscription-Tiers konfigurieren +
+
+ {!showAddForm && !editingId && ( + + )} +
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* Add/Edit Form */} + {(showAddForm || editingId) && ( +
+
+
+ {editingId ? 'Tier bearbeiten' : 'Neuen Tier erstellen'} +
+ +
+ +
+ {/* ID (nur bei Neuanlage) */} + {!editingId && ( +
+ + setFormData({ ...formData, id: e.target.value })} + placeholder="z.B. enterprise" + /> + + Kleinbuchstaben, keine Leerzeichen + +
+ )} + + {/* Name */} +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="z.B. Enterprise" + /> +
+ + {/* Description */} +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="z.B. Für Teams und Unternehmen" + /> +
+ + {/* Pricing */} +
+
+ + setFormData({ ...formData, price_monthly_cents: e.target.value })} + placeholder="Leer = kostenlos" + /> + + {formData.price_monthly_cents ? formatPrice(parseInt(formData.price_monthly_cents)) : '-'} + +
+ +
+ + setFormData({ ...formData, price_yearly_cents: e.target.value })} + placeholder="Leer = kostenlos" + /> + + {formData.price_yearly_cents ? formatPrice(parseInt(formData.price_yearly_cents)) : '-'} + +
+
+ + {/* Sort Order + Active */} +
+
+ + setFormData({ ...formData, sort_order: parseInt(e.target.value) || 50 })} + /> +
+ + +
+ + {/* Actions */} +
+ + +
+
+
+ )} + + {/* Tiers List */} +
+ {tiers.length === 0 && ( +
+ Keine Tiers vorhanden +
+ )} + {tiers.map(tier => ( +
+
+
+
+
+ {tier.name} +
+ + {tier.id} + + {!tier.active && ( + + INAKTIV + + )} +
+ + {tier.description && ( +
+ {tier.description} +
+ )} + +
+
+ Monatlich: {formatPrice(tier.price_monthly_cents)} +
+
+ Jährlich: {formatPrice(tier.price_yearly_cents)} +
+
+ Sortierung: {tier.sort_order} +
+
+
+ +
+ + +
+
+
+ ))} +
+ + {/* Info */} +
+ Hinweis: Limits für jeden Tier können in der{' '} + + Tier Limits Matrix + {' '} + konfiguriert werden. +
+
+ ) +} diff --git a/frontend/src/pages/AdminUserRestrictionsPage.jsx b/frontend/src/pages/AdminUserRestrictionsPage.jsx new file mode 100644 index 0000000..5b5e3d8 --- /dev/null +++ b/frontend/src/pages/AdminUserRestrictionsPage.jsx @@ -0,0 +1,538 @@ +import { useState, useEffect } from 'react' +import { Save, AlertCircle, X, RotateCcw } from 'lucide-react' +import { api } from '../utils/api' + +export default function AdminUserRestrictionsPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [users, setUsers] = useState([]) + const [features, setFeatures] = useState([]) + const [selectedUserId, setSelectedUserId] = useState('') + const [selectedUser, setSelectedUser] = useState(null) + const [restrictions, setRestrictions] = useState([]) + const [tierLimits, setTierLimits] = useState({}) + const [changes, setChanges] = useState({}) + const [saving, setSaving] = useState(false) + + useEffect(() => { + loadInitialData() + }, []) + + useEffect(() => { + if (selectedUserId) { + loadUserData(selectedUserId) + } else { + setSelectedUser(null) + setRestrictions([]) + setChanges({}) + } + }, [selectedUserId]) + + async function loadInitialData() { + try { + setLoading(true) + const [usersData, featuresData] = await Promise.all([ + api.adminListProfiles(), + api.listFeatures() + ]) + setUsers(usersData) + setFeatures(featuresData.filter(f => f.active)) + setError('') + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + async function loadUserData(userId) { + try { + const [user, restrictionsData, limitsMatrix] = await Promise.all([ + api.adminListProfiles().then(users => users.find(u => u.id === userId)), + api.listUserRestrictions(userId), + api.getTierLimitsMatrix() + ]) + + setSelectedUser(user) + setRestrictions(restrictionsData) + + // Build tier limits lookup for this user's tier + const userTier = user.tier || 'free' + const limits = {} + features.forEach(feature => { + const key = `${userTier}:${feature.id}` + // Use same fallback logic as TierLimitsPage: undefined → null (unlimited) + limits[feature.id] = limitsMatrix.limits[key] ?? null + }) + setTierLimits(limits) + + setChanges({}) + setError('') + setSuccess('') + } catch (e) { + setError(e.message) + } + } + + function handleChange(featureId, value) { + const newChanges = { ...changes } + const tierLimit = tierLimits[featureId] + + // Parse value (EXACTLY like TierLimitsPage) + let parsedValue = null + if (value === 'unlimited' || value === '∞') { + parsedValue = null // unlimited + } else if (value === '0' || value === 'disabled') { + parsedValue = 0 // disabled + } else if (value === '') { + parsedValue = null // empty → unlimited + } else { + const num = parseInt(value) + if (!isNaN(num) && num >= 0) { + parsedValue = num + } else { + return // invalid input, ignore + } + } + + // Check if value equals tier limit → remove override + if (parsedValue === tierLimit) { + newChanges[featureId] = { action: 'remove', tempValue: value } + } else { + // Different from tier default → set override + newChanges[featureId] = { action: 'set', value: parsedValue, tempValue: value } + } + + setChanges(newChanges) + } + + function handleToggle(featureId) { + // Get current state + const restriction = restrictions.find(r => r.feature_id === featureId) + let currentValue = restriction?.limit_value ?? null + + // Check if there's a pending change + if (featureId in changes && changes[featureId].action === 'set') { + currentValue = changes[featureId].value + } + + // Toggle between 1 (enabled) and 0 (disabled) + const isCurrentlyEnabled = currentValue !== 0 && currentValue !== '0' + const newValue = isCurrentlyEnabled ? 0 : 1 + + const newChanges = { ...changes } + newChanges[featureId] = { action: 'set', value: newValue, tempValue: newValue.toString() } + setChanges(newChanges) + } + + async function handleSave() { + if (!selectedUserId) return + + try { + setSaving(true) + setError('') + setSuccess('') + + let changeCount = 0 + + for (const [featureId, change] of Object.entries(changes)) { + const existingRestriction = restrictions.find(r => r.feature_id === featureId) + + if (change.action === 'remove') { + // Remove restriction if exists + if (existingRestriction) { + await api.deleteUserRestriction(existingRestriction.id) + changeCount++ + } + } else if (change.action === 'set') { + // Create or update + if (existingRestriction) { + await api.updateUserRestriction(existingRestriction.id, { + limit_value: change.value, + enabled: true + }) + } else { + await api.createUserRestriction({ + profile_id: selectedUserId, + feature_id: featureId, + limit_value: change.value, + enabled: true, + reason: 'Admin override' + }) + } + changeCount++ + } + } + + setSuccess(`${changeCount} Änderung(en) gespeichert`) + await loadUserData(selectedUserId) + } catch (e) { + setError(e.message) + } finally { + setSaving(false) + } + } + + function getDisplayValue(featureId) { + // Check pending changes first + if (featureId in changes) { + const change = changes[featureId] + if (change.action === 'remove') { + // Returning to tier default + return formatValue(tierLimits[featureId]) + } + if (change.action === 'set') { + // Use tempValue for display if available, otherwise format the value + return change.tempValue !== undefined ? change.tempValue : formatValue(change.value) + } + } + + // Show override if exists, otherwise tier limit (= effective value) + const restriction = restrictions.find(r => r.feature_id === featureId) + if (restriction) { + return formatValue(restriction.limit_value) + } + + // No override: show tier limit as default + return formatValue(tierLimits[featureId]) + } + + function formatValue(val) { + if (val === null || val === undefined) return 'unlimited' + if (val === '' ) return '' + if (val === '∞' || val === 'unlimited') return 'unlimited' + if (val === 0 || val === '0') return '0' + return val.toString() + } + + function getToggleState(featureId) { + // Check pending changes first + if (featureId in changes && changes[featureId].action === 'set') { + const val = changes[featureId].value + return val !== 0 && val !== '0' + } + + // Check existing restriction + const restriction = restrictions.find(r => r.feature_id === featureId) + if (!restriction) { + // No override: use tier default + const tierLimit = tierLimits[featureId] + return tierLimit !== 0 && tierLimit !== '0' + } + + // For boolean features: limit_value determines state + return restriction.limit_value !== 0 && restriction.limit_value !== '0' + } + + function hasOverride(featureId) { + return restrictions.some(r => r.feature_id === featureId) + } + + function isChanged(featureId) { + return featureId in changes + } + + if (loading) return ( +
+
+
+ ) + + const hasChanges = Object.keys(changes).length > 0 + const categoryGroups = {} + features.forEach(f => { + if (!categoryGroups[f.category]) categoryGroups[f.category] = [] + categoryGroups[f.category].push(f) + }) + + const categoryIcons = { data: '📊', ai: '🤖', export: '📤', integration: '🔗' } + const categoryNames = { data: 'DATEN', ai: 'KI', export: 'EXPORT', integration: 'INTEGRATIONEN' } + + return ( +
+ {/* Header */} +
+
+ User Feature-Overrides +
+
+ Individuelle Feature-Limits für einzelne User setzen +
+
+ + {/* Info Box */} +
+ +
+ Hinweis: Felder zeigen effektive Werte (Override falls gesetzt, sonst Tier-Standard). + Wert ändern → Override wird gesetzt. Wert = Tier-Standard → Override wird entfernt. +
+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {success && ( +
+ {success} +
+ )} + + {/* User Selection */} +
+ + +
+ + {/* User Info + Features */} + {selectedUser && ( + <> + {/* Action Buttons */} +
+
+ Feature-Overrides für {selectedUser.name} +
+
+ {hasChanges && ( + + )} + +
+
+ + {/* User Info Card */} +
+
+
+ {selectedUser.name?.charAt(0).toUpperCase()} +
+
+
{selectedUser.name}
+
+ {selectedUser.email || `ID: ${selectedUser.id}`} +
+
+
+ Tier: {selectedUser.tier || 'free'} +
+
+
+ + {/* Features Table */} +
+ + + + + + + + + + + {Object.entries(categoryGroups).map(([category, categoryFeatures]) => ( + <> + {/* Category Header */} + + + + + {/* Feature Rows */} + {categoryFeatures.map(feature => { + const displayValue = getDisplayValue(feature.id) + const toggleState = getToggleState(feature.id) + const override = hasOverride(feature.id) + const changed = isChanged(feature.id) + + return ( + + {/* Feature Name */} + + + {/* Tier-Limit */} + + + {/* Override Input */} + + + {/* Action */} + + + ) + })} + + ))} + +
+ Feature + + Tier-Limit + + Override-Wert + + Aktion +
+ {categoryIcons[category]} {categoryNames[category] || category} +
+
{feature.name}
+
+ {feature.limit_type === 'boolean' ? '(ja/nein)' : `(${feature.reset_period})`} +
+
+ {feature.limit_type === 'boolean' ? ( + + {tierLimits[feature.id] !== 0 ? '✓ AN' : '✗ AUS'} + + ) : ( + + {tierLimits[feature.id] === null ? '∞' : tierLimits[feature.id]} + + )} + + {feature.limit_type === 'boolean' ? ( + + ) : ( + handleChange(feature.id, e.target.value)} + placeholder="" + style={{ + width: '120px', + padding: '6px 8px', + border: `1.5px solid ${changed ? 'var(--accent)' : override ? 'var(--accent)' : 'var(--border)'}`, + borderRadius: 6, + textAlign: 'center', + fontSize: 13, + fontWeight: override || changed ? 600 : 400, + background: override || changed ? 'var(--accent-light)' : 'var(--bg)', + color: displayValue === '0' ? 'var(--danger)' : + displayValue === 'unlimited' ? 'var(--accent)' : 'var(--text1)' + }} + /> + )} + + +
+
+ + + {/* Legend */} +
+ Eingabe: +
+ unlimited = Unbegrenzt + 0 = Feature deaktiviert + 1+ = Limit-Wert +
+
+ • Feld zeigt effektiven Wert (Override falls gesetzt, sonst Tier-Standard)
+ • Wert ändern → Override wird gesetzt
+ • Wert = Tier-Standard → Override wird entfernt +
+
+ + )} + +
+ ) +} diff --git a/frontend/src/pages/Analysis.jsx b/frontend/src/pages/Analysis.jsx index 630bc1b..5d50ad2 100644 --- a/frontend/src/pages/Analysis.jsx +++ b/frontend/src/pages/Analysis.jsx @@ -3,6 +3,7 @@ import { Brain, Pencil, Trash2, ChevronDown, ChevronUp, Check, X } from 'lucide- import { api } from '../utils/api' import { useAuth } from '../context/AuthContext' import Markdown from '../utils/Markdown' +import UsageBadge from '../components/UsageBadge' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -114,6 +115,7 @@ export default function Analysis() { const [tab, setTab] = useState('run') const [newResult, setNewResult] = useState(null) const [pipelineLoading, setPipelineLoading] = useState(false) + const [aiUsage, setAiUsage] = useState(null) // Phase 3: Usage badge const loadAll = async () => { const [p, i] = await Promise.all([ @@ -123,7 +125,15 @@ export default function Analysis() { setPrompts(Array.isArray(p)?p:[]) setAllInsights(Array.isArray(i)?i:[]) } - useEffect(()=>{ loadAll() },[]) + + useEffect(()=>{ + loadAll() + // Load feature usage for badges + api.getFeatureUsage().then(features => { + const aiFeature = features.find(f => f.feature_id === 'ai_calls') + setAiUsage(aiFeature) + }).catch(err => console.error('Failed to load usage:', err)) + },[]) const runPipeline = async () => { setPipelineLoading(true); setError(null); setNewResult(null) @@ -177,7 +187,7 @@ export default function Analysis() { grouped[key].push(ins) }) - const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_')) + const activePrompts = prompts.filter(p=>p.active && !p.slug.startsWith('pipeline_') && p.slug !== 'pipeline') // Pipeline is available if the "pipeline" prompt is active const pipelinePrompt = prompts.find(p=>p.slug==='pipeline') @@ -230,7 +240,10 @@ export default function Analysis() {
-
🔬 Mehrstufige Gesamtanalyse
+
+ 🔬 Mehrstufige Gesamtanalyse + {aiUsage && } +
3 spezialisierte KI-Calls parallel (Körper + Ernährung + Aktivität), dann Synthese + Zielabgleich. Detaillierteste Auswertung. @@ -241,12 +254,22 @@ export default function Analysis() {
)}
- +
+ +
{!canUseAI &&
🔒 KI nicht freigeschaltet
}
{pipelineLoading && ( @@ -282,7 +305,10 @@ export default function Analysis() {
-
{SLUG_LABELS[p.slug]||p.name}
+
+ {SLUG_LABELS[p.slug]||p.name} + {aiUsage && } +
{p.description &&
{p.description}
} {existing && (
@@ -290,12 +316,22 @@ export default function Analysis() {
)}
- +
+ +
{/* Show existing result collapsed */} {existing && newResult?.id !== existing.id && ( diff --git a/frontend/src/pages/CaliperScreen.jsx b/frontend/src/pages/CaliperScreen.jsx index f81bf2e..0b1564e 100644 --- a/frontend/src/pages/CaliperScreen.jsx +++ b/frontend/src/pages/CaliperScreen.jsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom' import { api } from '../utils/api' import { calcBodyFat, getBfCategory, METHOD_POINTS } from '../utils/calc' import { CALIPER_POINTS, CALIPER_METHODS } from '../utils/guideData' +import UsageBadge from '../components/UsageBadge' import dayjs from 'dayjs' function emptyForm() { @@ -15,7 +16,7 @@ function emptyForm() { } } -function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern' }) { +function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Speichern', saving=false, error=null, usage=null }) { const sex = profile?.sex||'m' const age = profile?.dob ? Math.floor((Date.now()-new Date(profile.dob))/(365.25*24*3600*1000)) : 30 const weight = form.weight || 80 @@ -65,8 +66,25 @@ function CaliperForm({ form, setForm, profile, onSave, onCancel, saveLabel='Spei set('notes',e.target.value)}/>
+ {error && ( +
+ {error} +
+ )}
- +
+ +
{onCancel && }
@@ -78,12 +96,26 @@ export default function CaliperScreen() { const [profile, setProfile] = useState(null) const [form, setForm] = useState(emptyForm()) const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [caliperUsage, setCaliperUsage] = useState(null) // Phase 4: Usage badge const nav = useNavigate() const load = () => Promise.all([api.listCaliper(), api.getProfile()]) .then(([e,p])=>{ setEntries(e); setProfile(p) }) - useEffect(()=>{ load() },[]) + + const loadUsage = () => { + api.getFeatureUsage().then(features => { + const caliperFeature = features.find(f => f.feature_id === 'caliper_entries') + setCaliperUsage(caliperFeature) + }).catch(err => console.error('Failed to load usage:', err)) + } + + useEffect(()=>{ + load() + loadUsage() + },[]) const buildPayload = (f, bfPct, sex) => { const weight = profile?.weight || null @@ -97,11 +129,23 @@ export default function CaliperScreen() { } const handleSave = async (bfPct, sex) => { - const payload = buildPayload(form, bfPct, sex) - await api.upsertCaliper(payload) - setSaved(true); await load() - setTimeout(()=>setSaved(false),2000) - setForm(emptyForm()) + setSaving(true) + setError(null) + try { + const payload = buildPayload(form, bfPct, sex) + await api.upsertCaliper(payload) + setSaved(true) + await load() + await loadUsage() // Reload usage after save + setTimeout(()=>setSaved(false),2000) + setForm(emptyForm()) + } catch (err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(()=>setError(null), 5000) + } finally { + setSaving(false) + } } const handleUpdate = async (bfPct, sex) => { @@ -125,9 +169,13 @@ export default function CaliperScreen() {
-
Neue Messung
+
+ Neue Messung + {caliperUsage && } +
+ onSave={handleSave} saveLabel={saved?'✓ Gespeichert!':'Speichern'} + saving={saving} error={error} usage={caliperUsage}/>
diff --git a/frontend/src/pages/CircumScreen.jsx b/frontend/src/pages/CircumScreen.jsx index 22b37e4..ea1fb5e 100644 --- a/frontend/src/pages/CircumScreen.jsx +++ b/frontend/src/pages/CircumScreen.jsx @@ -3,6 +3,7 @@ import { Pencil, Trash2, Check, X, Camera, BookOpen } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { api } from '../utils/api' import { CIRCUMFERENCE_POINTS } from '../utils/guideData' +import UsageBadge from '../components/UsageBadge' import dayjs from 'dayjs' const FIELDS = ['c_neck','c_chest','c_waist','c_belly','c_hip','c_thigh','c_calf','c_arm'] @@ -16,18 +17,32 @@ export default function CircumScreen() { const [editing, setEditing] = useState(null) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) const [photoFile, setPhotoFile] = useState(null) const [photoPreview, setPhotoPreview] = useState(null) + const [circumUsage, setCircumUsage] = useState(null) // Phase 4: Usage badge const fileRef = useRef() const nav = useNavigate() const load = () => api.listCirc().then(setEntries) - useEffect(()=>{ load() },[]) + + const loadUsage = () => { + api.getFeatureUsage().then(features => { + const circumFeature = features.find(f => f.feature_id === 'circumference_entries') + setCircumUsage(circumFeature) + }).catch(err => console.error('Failed to load usage:', err)) + } + + useEffect(()=>{ + load() + loadUsage() + },[]) const set = (k,v) => setForm(f=>({...f,[k]:v})) const handleSave = async () => { setSaving(true) + setError(null) try { const payload = {} payload.date = form.date @@ -38,10 +53,18 @@ export default function CircumScreen() { payload.photo_id = pr.id } await api.upsertCirc(payload) - setSaved(true); await load() + setSaved(true) + await load() + await loadUsage() // Reload usage after save setTimeout(()=>setSaved(false),2000) setForm(empty()); setPhotoFile(null); setPhotoPreview(null) - } finally { setSaving(false) } + } catch (err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(()=>setError(null), 5000) + } finally { + setSaving(false) + } } const startEdit = (e) => setEditing({...e}) @@ -72,7 +95,10 @@ export default function CircumScreen() { {/* Eingabe */}
-
Neue Messung
+
+ Neue Messung + {circumUsage && } +
set('date',e.target.value)}/> @@ -99,9 +125,27 @@ export default function CircumScreen() { - + {error && ( +
+ {error} +
+ )} +
+ +
{/* Liste */} diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index d9d6c59..5ec20b6 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -27,32 +27,75 @@ function QuickWeight({ onSaved }) { const [input, setInput] = useState('') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [weightUsage, setWeightUsage] = useState(null) const today = dayjs().format('YYYY-MM-DD') + const loadUsage = () => { + api.getFeatureUsage().then(features => { + const weightFeature = features.find(f => f.feature_id === 'weight_entries') + setWeightUsage(weightFeature) + }).catch(err => console.error('Failed to load usage:', err)) + } + useEffect(()=>{ api.weightStats().then(s=>{ if(s?.latest?.date===today) setInput(String(s.latest.weight)) }) + loadUsage() },[]) const handleSave = async () => { const w=parseFloat(input); if(!w||w<20||w>300) return setSaving(true) - try{ await api.upsertWeight(today,w); setSaved(true); onSaved?.(); setTimeout(()=>setSaved(false),2000) } - finally{ setSaving(false) } + setError(null) + try{ + await api.upsertWeight(today,w) + setSaved(true) + await loadUsage() // Reload usage after save + onSaved?.() + setTimeout(()=>setSaved(false),2000) + } catch(err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(()=>setError(null), 5000) + } finally { + setSaving(false) + } } + const isDisabled = saving || !input || (weightUsage && !weightUsage.allowed) + const tooltipText = weightUsage && !weightUsage.allowed + ? `Limit erreicht (${weightUsage.used}/${weightUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` + : '' + return ( -
- setInput(e.target.value)} - onKeyDown={e=>e.key==='Enter'&&handleSave()}/> - kg - +
+ {error && ( +
+ {error} +
+ )} +
+ setInput(e.target.value)} + onKeyDown={e=>e.key==='Enter'&&!isDisabled&&handleSave()}/> + kg +
+ +
+
) } diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 04281c8..ba48643 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -18,6 +18,419 @@ function rollingAvg(arr, key, window=7) { }) } +// ── Entry Form (Create/Update) ─────────────────────────────────────────────── +function EntryForm({ onSaved }) { + const [date, setDate] = useState(dayjs().format('YYYY-MM-DD')) + const [values, setValues] = useState({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' }) + const [existingId, setExistingId] = useState(null) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + + // Load data for selected date + useEffect(() => { + const load = async () => { + if (!date) return + setLoading(true) + setError(null) + try { + const data = await nutritionApi.getNutritionByDate(date) + if (data) { + setValues({ + kcal: data.kcal || '', + protein_g: data.protein_g || '', + fat_g: data.fat_g || '', + carbs_g: data.carbs_g || '' + }) + setExistingId(data.id) + } else { + setValues({ kcal: '', protein_g: '', fat_g: '', carbs_g: '' }) + setExistingId(null) + } + } catch(e) { + console.error('Failed to load entry:', e) + } finally { + setLoading(false) + } + } + load() + }, [date]) + + const handleSave = async () => { + if (!date || !values.kcal) { + setError('Datum und Kalorien sind Pflichtfelder') + return + } + + setSaving(true) + setError(null) + setSuccess(null) + try { + const result = await nutritionApi.createNutrition( + date, + parseFloat(values.kcal) || 0, + parseFloat(values.protein_g) || 0, + parseFloat(values.fat_g) || 0, + parseFloat(values.carbs_g) || 0 + ) + setSuccess(result.mode === 'created' ? 'Eintrag hinzugefügt' : 'Eintrag aktualisiert') + setTimeout(() => setSuccess(null), 3000) + onSaved() + } catch(e) { + if (e.message.includes('Limit erreicht')) { + setError(e.message) + } else { + setError('Speichern fehlgeschlagen: ' + e.message) + } + setTimeout(() => setError(null), 5000) + } finally { + setSaving(false) + } + } + + return ( +
+
Eintrag hinzufügen / bearbeiten
+ + {error && ( +
+ {error} +
+ )} + {success && ( +
+ ✓ {success} +
+ )} + +
+
+ + setDate(e.target.value)} + max={dayjs().format('YYYY-MM-DD')} + style={{width:'100%'}} + /> + {existingId && !loading && ( +
+ ℹ️ Eintrag existiert bereits – wird beim Speichern aktualisiert +
+ )} +
+ +
+ + setValues({...values, kcal: e.target.value})} + placeholder="z.B. 2000" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, protein_g: e.target.value})} + placeholder="z.B. 150" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, fat_g: e.target.value})} + placeholder="z.B. 80" + disabled={loading} + style={{width:'100%'}} + /> +
+ +
+ + setValues({...values, carbs_g: e.target.value})} + placeholder="z.B. 200" + disabled={loading} + style={{width:'100%'}} + /> +
+
+ + +
+ ) +} + +// ── Data Tab (Editable Entry List) ─────────────────────────────────────────── +function DataTab({ entries, onUpdate }) { + const [editId, setEditId] = useState(null) + const [editValues, setEditValues] = useState({}) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('30') // days to show (7, 30, 90, 'all') + + const startEdit = (e) => { + setEditId(e.id) + setEditValues({ + kcal: e.kcal || 0, + protein_g: e.protein_g || 0, + fat_g: e.fat_g || 0, + carbs_g: e.carbs_g || 0 + }) + } + + const cancelEdit = () => { + setEditId(null) + setEditValues({}) + setError(null) + } + + const saveEdit = async (id) => { + setSaving(true) + setError(null) + try { + await nutritionApi.updateNutrition( + id, + editValues.kcal, + editValues.protein_g, + editValues.fat_g, + editValues.carbs_g + ) + setEditId(null) + setEditValues({}) + onUpdate() + } catch(e) { + setError('Speichern fehlgeschlagen: ' + e.message) + } finally { + setSaving(false) + } + } + + const deleteEntry = async (id, date) => { + if (!confirm(`Eintrag vom ${dayjs(date).format('DD.MM.YYYY')} wirklich löschen?`)) return + try { + await nutritionApi.deleteNutrition(id) + onUpdate() + } catch(e) { + setError('Löschen fehlgeschlagen: ' + e.message) + } + } + + // Filter entries by date range + const filteredEntries = filter === 'all' + ? entries + : entries.filter(e => { + const daysDiff = dayjs().diff(dayjs(e.date), 'day') + return daysDiff <= parseInt(filter) + }) + + if (entries.length === 0) { + return ( +
+
Alle Einträge (0)
+

Noch keine Ernährungsdaten. Importiere FDDB CSV oben.

+
+ ) + } + + return ( +
+
+
+ Alle Einträge ({filteredEntries.length}{filteredEntries.length !== entries.length ? ` von ${entries.length}` : ''}) +
+ +
+ {error && ( +
+ {error} +
+ )} + {filteredEntries.map((e, i) => { + const isEditing = editId === e.id + return ( +
+ {!isEditing ? ( + <> +
+ {dayjs(e.date).format('dd, DD. MMMM YYYY')} +
+ + +
+
+
+ {Math.round(e.kcal || 0)} kcal +
+
+ 🥩 Protein: {Math.round(e.protein_g || 0)}g + 🫙 Fett: {Math.round(e.fat_g || 0)}g + 🍞 Kohlenhydrate: {Math.round(e.carbs_g || 0)}g +
+ {e.source && ( +
+ Quelle: {e.source} +
+ )} + + ) : ( + <> +
+ {dayjs(e.date).format('dd, DD. MMMM YYYY')} +
+
+
+ + setEditValues({...editValues, kcal: parseFloat(e.target.value)||0})} + style={{width:'100%'}}/> +
+
+ + setEditValues({...editValues, protein_g: parseFloat(e.target.value)||0})} + style={{width:'100%'}}/> +
+
+ + setEditValues({...editValues, fat_g: parseFloat(e.target.value)||0})} + style={{width:'100%'}}/> +
+
+ + setEditValues({...editValues, carbs_g: parseFloat(e.target.value)||0})} + style={{width:'100%'}}/> +
+
+
+ + +
+ + )} +
+ ) + })} +
+ ) +} + +// ── Import History ──────────────────────────────────────────────────────────── +function ImportHistory() { + const [history, setHistory] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const load = async () => { + try { + const data = await nutritionApi.nutritionImportHistory() + setHistory(Array.isArray(data) ? data : []) + } catch(e) { + console.error('Failed to load import history:', e) + } finally { + setLoading(false) + } + } + load() + }, []) + + if (loading) return null + if (!history.length) return null + + return ( +
+
Import-Historie
+
+ {history.map((h, i) => ( +
+
+ {dayjs(h.import_date).format('DD.MM.YYYY')} + + {dayjs(h.last_created).format('HH:mm')} Uhr + +
+
+ {h.count} {h.count === 1 ? 'Eintrag' : 'Einträge'} + {h.date_from && h.date_to && ( + + ({dayjs(h.date_from).format('DD.MM.YY')} – {dayjs(h.date_to).format('DD.MM.YY')}) + + )} +
+
+ ))} +
+
+ ) +} + // ── Import Panel ────────────────────────────────────────────────────────────── function ImportPanel({ onImported }) { const fileRef = useRef() @@ -322,9 +735,11 @@ function CalorieBalance({ data, profile }) { // ── Main Page ───────────────────────────────────────────────────────────────── export default function NutritionPage() { - const [tab, setTab] = useState('overview') + const [inputTab, setInputTab] = useState('entry') // 'entry' or 'import' + const [analysisTab,setAnalysisTab] = useState('data') const [corrData, setCorr] = useState([]) const [weekly, setWeekly] = useState([]) + const [entries, setEntries]= useState([]) const [profile, setProf] = useState(null) const [loading, setLoad] = useState(true) const [hasData, setHasData]= useState(false) @@ -332,13 +747,15 @@ export default function NutritionPage() { const load = async () => { setLoad(true) try { - const [corr, wkly, prof] = await Promise.all([ + const [corr, wkly, ent, prof] = await Promise.all([ nutritionApi.nutritionCorrelations(), nutritionApi.nutritionWeekly(16), - api.getActiveProfile(), + nutritionApi.listNutrition(365), // BUG-002 fix: load raw entries + nutritionApi.getActiveProfile(), ]) setCorr(Array.isArray(corr)?corr:[]) setWeekly(Array.isArray(wkly)?wkly:[]) + setEntries(Array.isArray(ent)?ent:[]) // BUG-002 fix setProf(prof) setHasData(Array.isArray(corr) && corr.some(d=>d.kcal)) } catch(e) { console.error('load error:', e) } @@ -351,29 +768,52 @@ export default function NutritionPage() {

Ernährung

- + {/* Input Method Tabs */} +
+ + +
+ + {/* Entry Form */} + {inputTab==='entry' && } + + {/* Import Panel + History */} + {inputTab==='import' && ( + <> + + + + )} {loading &&
} {!loading && !hasData && (

Noch keine Ernährungsdaten

-

Importiere deinen FDDB-Export oben um Auswertungen zu sehen.

+

Erfasse Daten über Einzelerfassung oder importiere deinen FDDB-Export.

)} + {/* Analysis Section */} {!loading && hasData && ( <>
- - - - + + + + +
- {tab==='overview' && ( + {analysisTab==='data' && } + + {analysisTab==='overview' && (
Makro-Verteilung pro Woche (Ø g/Tag)
@@ -385,7 +825,7 @@ export default function NutritionPage() {
)} - {tab==='weight' && ( + {analysisTab==='weight' && (
Kalorien vs. Gewichtsverlauf
@@ -401,7 +841,7 @@ export default function NutritionPage() {
)} - {tab==='protein' && ( + {analysisTab==='protein' && (
Protein vs. Magermasse
@@ -417,7 +857,7 @@ export default function NutritionPage() {
)} - {tab==='balance' && ( + {analysisTab==='balance' && (
Kaloriendefizit / -überschuss
diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index b5a0de0..fb49dcf 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,10 +1,12 @@ -import { useState } from 'react' -import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key } from 'lucide-react' +import { useState, useEffect } from 'react' +import { Save, Download, Upload, Trash2, Plus, Check, Pencil, X, LogOut, Shield, Key, BarChart3 } from 'lucide-react' import { useProfile } from '../context/ProfileContext' import { useAuth } from '../context/AuthContext' import { Avatar } from './ProfileSelect' import { api } from '../utils/api' import AdminPanel from './AdminPanel' +import FeatureUsageOverview from '../components/FeatureUsageOverview' +import UsageBadge from '../components/UsageBadge' const COLORS = ['#1D9E75','#378ADD','#D85A30','#EF9F27','#7F77DD','#D4537E','#639922','#888780'] @@ -99,6 +101,15 @@ export default function SettingsPage() { const [pinOpen, setPinOpen] = useState(false) const [newPin, setNewPin] = useState('') const [pinMsg, setPinMsg] = useState(null) + const [exportUsage, setExportUsage] = useState(null) // Phase 3: Usage badge + + // Load feature usage for export badges + useEffect(() => { + api.getFeatureUsage().then(features => { + const exportFeature = features.find(f => f.feature_id === 'data_export') + setExportUsage(exportFeature) + }).catch(err => console.error('Failed to load usage:', err)) + }, []) const handleLogout = async () => { if (!confirm('Ausloggen?')) return @@ -326,6 +337,17 @@ export default function SettingsPage() {
+ {/* Feature Usage Overview (Phase 3) */} +
+
+ Kontingente +
+

+ Übersicht über deine Feature-Nutzung und verfügbare Kontingente. +

+ +
+ {/* Admin Panel */} {isAdmin && (
@@ -359,13 +381,23 @@ export default function SettingsPage() { {canExport && <> }
diff --git a/frontend/src/pages/SubscriptionPage.jsx b/frontend/src/pages/SubscriptionPage.jsx new file mode 100644 index 0000000..619e246 --- /dev/null +++ b/frontend/src/pages/SubscriptionPage.jsx @@ -0,0 +1,241 @@ +import { useState, useEffect } from 'react' +import { Gift, AlertCircle, TrendingUp, Award } from 'lucide-react' +import { api } from '../utils/api' + +export default function SubscriptionPage() { + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [subscription, setSubscription] = useState(null) + const [usage, setUsage] = useState([]) + const [limits, setLimits] = useState([]) + const [couponCode, setCouponCode] = useState('') + const [redeeming, setRedeeming] = useState(false) + const [couponSuccess, setCouponSuccess] = useState('') + + useEffect(() => { + loadData() + }, []) + + async function loadData() { + try { + setLoading(true) + const [subData, usageData, limitsData] = await Promise.all([ + api.getMySubscription(), + api.getMyUsage(), + api.getMyLimits() + ]) + setSubscription(subData) + setUsage(usageData) + setLimits(limitsData) + setError('') + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + async function handleRedeemCoupon() { + if (!couponCode.trim()) return + + try { + setRedeeming(true) + setError('') + setCouponSuccess('') + await api.redeemCoupon(couponCode.trim().toUpperCase()) + setCouponSuccess('Coupon erfolgreich eingelöst!') + setCouponCode('') + await loadData() + } catch (e) { + setError(e.message) + } finally { + setRedeeming(false) + } + } + + if (loading) return ( +
+
+
+ ) + + const tierColors = { + free: { bg: 'var(--surface2)', color: 'var(--text2)', icon: '🆓' }, + basic: { bg: '#E3F2FD', color: '#1565C0', icon: '⭐' }, + premium: { bg: '#F3E5F5', color: '#6A1B9A', icon: '👑' }, + selfhosted: { bg: 'var(--accent-light)', color: 'var(--accent-dark)', icon: '🏠' } + } + + const currentTier = subscription?.current_tier || 'free' + const tierStyle = tierColors[currentTier] || tierColors.free + + return ( +
+ {/* Header */} +
+
+ Mein Abo +
+
+ Tier, Limits und Nutzung +
+
+ + {/* Messages */} + {error && ( +
+ {error} +
+ )} + {couponSuccess && ( +
+ {couponSuccess} +
+ )} + + {/* Current Tier Card */} +
+
+
+ {tierStyle.icon} +
+
+
+ Aktueller Tier +
+
+ {currentTier.charAt(0).toUpperCase() + currentTier.slice(1)} +
+
+
+ + {subscription?.trial_ends_at && new Date(subscription.trial_ends_at) > new Date() && ( +
+ +
+ Trial aktiv: Endet am {new Date(subscription.trial_ends_at).toLocaleDateString('de-DE')} +
+
+ )} + + {subscription?.access_until && ( +
+ +
+ Zugriff bis: {new Date(subscription.access_until).toLocaleDateString('de-DE')} +
+
+ )} +
+ + {/* Feature Limits */} +
+
+ + Feature-Limits & Nutzung +
+ + {limits.length === 0 ? ( +
+ Keine Limits konfiguriert +
+ ) : ( +
+ {limits.map(limit => { + const usageEntry = usage.find(u => u.feature_id === limit.feature_id) + const used = usageEntry?.usage_count || 0 + const limitValue = limit.limit_value + const percentage = limitValue ? Math.min((used / limitValue) * 100, 100) : 0 + + return ( +
+
+
{limit.feature_name}
+
+ {used} / {limitValue === null ? '∞' : limitValue} +
+
+ + {limitValue !== null && ( +
+
90 ? 'var(--danger)' : percentage > 70 ? '#FFA726' : 'var(--accent)', + transition: 'width 0.3s' + }} /> +
+ )} + + {limit.reset_period !== 'never' && ( +
+ Reset: {limit.reset_period === 'daily' ? 'Täglich' : 'Monatlich'} +
+ )} +
+ ) + })} +
+ )} +
+ + {/* Coupon Redemption */} +
+
+ + Coupon einlösen +
+ +
+ Hast du einen Coupon-Code? Löse ihn hier ein um Zugriff auf Premium-Features zu erhalten. +
+ +
+ setCouponCode(e.target.value)} + placeholder="Z.B. PROMO-2026" + onKeyPress={(e) => e.key === 'Enter' && handleRedeemCoupon()} + /> + +
+
+
+ ) +} diff --git a/frontend/src/pages/WeightScreen.jsx b/frontend/src/pages/WeightScreen.jsx index 10dd81c..0f24a8a 100644 --- a/frontend/src/pages/WeightScreen.jsx +++ b/frontend/src/pages/WeightScreen.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react' import { Pencil, Trash2, Check, X } from 'lucide-react' import { api } from '../utils/api' import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, ReferenceLine } from 'recharts' +import UsageBadge from '../components/UsageBadge' import dayjs from 'dayjs' import 'dayjs/locale/de' dayjs.locale('de') @@ -21,19 +22,42 @@ export default function WeightScreen() { const [newNote, setNewNote] = useState('') const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + const [weightUsage, setWeightUsage] = useState(null) // Phase 3: Usage badge const load = () => api.listWeight(365).then(data => setEntries(data)) - useEffect(()=>{ load() },[]) + + const loadUsage = () => { + // Load feature usage for badge + api.getFeatureUsage().then(features => { + const weightFeature = features.find(f => f.feature_id === 'weight_entries') + setWeightUsage(weightFeature) + }).catch(err => console.error('Failed to load usage:', err)) + } + + useEffect(()=>{ + load() + loadUsage() + },[]) const handleSave = async () => { if (!newWeight) return setSaving(true) + setError(null) try { await api.upsertWeight(newDate, parseFloat(newWeight), newNote) - setSaved(true); await load() + setSaved(true) + await load() + await loadUsage() // Reload usage after save setTimeout(()=>setSaved(false), 2000) setNewWeight(''); setNewNote('') - } finally { setSaving(false) } + } catch (err) { + console.error('Save failed:', err) + setError(err.message || 'Fehler beim Speichern') + setTimeout(()=>setError(null), 5000) + } finally { + setSaving(false) + } } const handleUpdate = async () => { @@ -59,7 +83,10 @@ export default function WeightScreen() { {/* Eingabe */}
-
Eintrag hinzufügen
+
+ Eintrag hinzufügen + {weightUsage && } +
setNewNote(e.target.value)}/>
- + {error && ( +
+ {error} +
+ )} +
+ +
{/* Chart */} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a0634a1..bc4701e 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -82,6 +82,11 @@ export const api = { listNutrition: (l=365) => req(`/nutrition?limit=${l}`), nutritionCorrelations: () => req('/nutrition/correlations'), nutritionWeekly: (w=16) => req(`/nutrition/weekly?weeks=${w}`), + nutritionImportHistory: () => req('/nutrition/import-history'), + getNutritionByDate: (date) => req(`/nutrition/by-date/${date}`), + createNutrition: (date,kcal,protein,fat,carbs) => req(`/nutrition?date=${date}&kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'POST'}), + updateNutrition: (id,kcal,protein,fat,carbs) => req(`/nutrition/${id}?kcal=${kcal}&protein_g=${protein}&fat_g=${fat}&carbs_g=${carbs}`,{method:'PUT'}), + deleteNutrition: (id) => req(`/nutrition/${id}`,{method:'DELETE'}), // Stats & AI getStats: () => req('/stats'), @@ -137,4 +142,48 @@ export const api = { adminDeleteProfile: (id) => req(`/admin/profiles/${id}`,{method:'DELETE'}), adminSetPermissions: (id,d) => req(`/admin/profiles/${id}/permissions`,jput(d)), changePin: (pin) => req('/auth/pin',json({pin})), + + // v9c Subscription System + // User-facing + getMySubscription: () => req('/subscription/me'), + getMyUsage: () => req('/subscription/usage'), + getMyLimits: () => req('/subscription/limits'), + redeemCoupon: (code) => req('/coupons/redeem',json({code})), + getFeatureUsage: () => req('/features/usage'), // Phase 3: Usage overview + + // Admin: Features + listFeatures: () => req('/features'), + createFeature: (d) => req('/features',json(d)), + updateFeature: (id,d) => req(`/features/${id}`,jput(d)), + deleteFeature: (id) => req(`/features/${id}`,{method:'DELETE'}), + + // Admin: Tiers + listTiers: () => req('/tiers'), + createTier: (d) => req('/tiers',json(d)), + updateTier: (id,d) => req(`/tiers/${id}`,jput(d)), + deleteTier: (id) => req(`/tiers/${id}`,{method:'DELETE'}), + + // Admin: Tier Limits (Matrix) + getTierLimitsMatrix: () => req('/tier-limits'), + updateTierLimit: (d) => req('/tier-limits',jput(d)), + updateTierLimitsBatch:(updates) => req('/tier-limits/batch',jput({updates})), + + // Admin: User Restrictions + listUserRestrictions: (pid) => req(`/user-restrictions${pid?'?profile_id='+pid:''}`), + createUserRestriction:(d) => req('/user-restrictions',json(d)), + updateUserRestriction:(id,d) => req(`/user-restrictions/${id}`,jput(d)), + deleteUserRestriction:(id) => req(`/user-restrictions/${id}`,{method:'DELETE'}), + + // Admin: Coupons + listCoupons: () => req('/coupons'), + createCoupon: (d) => req('/coupons',json(d)), + updateCoupon: (id,d) => req(`/coupons/${id}`,jput(d)), + deleteCoupon: (id) => req(`/coupons/${id}`,{method:'DELETE'}), + getCouponRedemptions: (id) => req(`/coupons/${id}/redemptions`), + + // Admin: Access Grants + listAccessGrants: (pid,active)=> req(`/access-grants${pid?'?profile_id='+pid:''}${active?'&active_only=true':''}`), + createAccessGrant: (d) => req('/access-grants',json(d)), + updateAccessGrant: (id,d) => req(`/access-grants/${id}`,jput(d)), + revokeAccessGrant: (id) => req(`/access-grants/${id}`,{method:'DELETE'}), }