From de8c2472e223d8a94cd638555e481246579d3896 Mon Sep 17 00:00:00 2001 From: xds Date: Mon, 16 Mar 2026 14:46:20 +0300 Subject: [PATCH] fix --- .claude/settings.local.json | 3 +- ...add_coaching_tables_and_rider_coaching_.py | 51 ++ backend/app/api/activities.py | 218 +++++++- backend/app/api/coaching.py | 307 +++++++++++ backend/app/api/rider.py | 152 +++++- backend/app/api/router.py | 2 + backend/app/bot.py | 186 +++++++ backend/app/main.py | 21 +- backend/app/models/__init__.py | 2 + backend/app/models/coaching.py | 22 + backend/app/models/rider.py | 4 +- backend/app/models/training.py | 5 +- backend/app/schemas/activity.py | 40 ++ backend/app/schemas/rider.py | 13 + backend/app/services/ai_summary.py | 62 +++ backend/app/services/coaching.py | 461 ++++++++++++++++ backend/app/services/fitness.py | 107 ++++ backend/app/services/intervals.py | 88 +++ backend/app/services/metrics.py | 71 ++- backend/app/services/power_curve.py | 30 ++ backend/app/services/zones.py | 75 +++ fit.fit | Bin 0 -> 210450 bytes frontend/.env.development | 2 + frontend/.env.production | 2 + frontend/index.html | 2 +- frontend/src/App.vue | 47 +- frontend/src/composables/useApi.ts | 2 +- frontend/src/router.ts | 6 + frontend/src/stores/activities.ts | 83 ++- frontend/src/stores/auth.ts | 19 +- frontend/src/stores/coaching.ts | 102 ++++ frontend/src/style.css | 28 + frontend/src/types/models.ts | 151 ++++++ frontend/src/views/ActivitiesView.vue | 129 ++++- frontend/src/views/ActivityDetailView.vue | 505 +++++++++++++++++- frontend/src/views/CoachView.vue | 410 ++++++++++++++ frontend/src/views/DashboardView.vue | 305 ++++++++++- frontend/src/views/LoginView.vue | 6 +- frontend/src/views/SettingsView.vue | 135 ++++- .../5cc4d7f5-91ec-4e3d-b3a1-4289530ca7f1.fit | Bin 0 -> 210450 bytes .../752e7df3-6521-4192-aefa-e3876d5c27d2.fit | Bin 0 -> 261252 bytes .../8b3cea26-f065-4594-84f6-04b81938429d.fit | Bin 0 -> 261252 bytes .../99d144c6-d51a-4288-b5a0-0a5ba821960b.fit | Bin 0 -> 261252 bytes .../9e235a25-5a16-42fd-a854-e80fa68b8842.fit | Bin 0 -> 280927 bytes .../d509880d-8931-4443-a931-13f73c7b874b.fit | Bin 0 -> 155399 bytes 45 files changed, 3714 insertions(+), 140 deletions(-) create mode 100644 backend/alembic/versions/4c6a3c01542f_add_coaching_tables_and_rider_coaching_.py create mode 100644 backend/app/api/coaching.py create mode 100644 backend/app/bot.py create mode 100644 backend/app/models/coaching.py create mode 100644 backend/app/services/ai_summary.py create mode 100644 backend/app/services/coaching.py create mode 100644 backend/app/services/fitness.py create mode 100644 backend/app/services/intervals.py create mode 100644 backend/app/services/power_curve.py create mode 100644 backend/app/services/zones.py create mode 100644 fit.fit create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/src/stores/coaching.ts create mode 100644 frontend/src/views/CoachView.vue create mode 100644 uploads/5cc4d7f5-91ec-4e3d-b3a1-4289530ca7f1.fit create mode 100644 uploads/752e7df3-6521-4192-aefa-e3876d5c27d2.fit create mode 100644 uploads/8b3cea26-f065-4594-84f6-04b81938429d.fit create mode 100644 uploads/99d144c6-d51a-4288-b5a0-0a5ba821960b.fit create mode 100644 uploads/9e235a25-5a16-42fd-a854-e80fa68b8842.fit create mode 100644 uploads/d509880d-8931-4443-a931-13f73c7b874b.fit diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 152005a..58aa2a9 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -8,7 +8,8 @@ "WebFetch(domain:primevue.org)", "WebFetch(domain:core.telegram.org)", "WebFetch(domain:docs.telegram-mini-apps.com)", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(find:*)" ] } } diff --git a/backend/alembic/versions/4c6a3c01542f_add_coaching_tables_and_rider_coaching_.py b/backend/alembic/versions/4c6a3c01542f_add_coaching_tables_and_rider_coaching_.py new file mode 100644 index 0000000..09e9501 --- /dev/null +++ b/backend/alembic/versions/4c6a3c01542f_add_coaching_tables_and_rider_coaching_.py @@ -0,0 +1,51 @@ +"""add coaching tables and rider coaching fields + +Revision ID: 4c6a3c01542f +Revises: 928b78044640 +Create Date: 2026-03-16 14:26:46.753457 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '4c6a3c01542f' +down_revision: Union[str, None] = '928b78044640' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('coaching_chats', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('rider_id', sa.UUID(), nullable=False), + sa.Column('chat_type', sa.String(length=50), nullable=False), + sa.Column('messages_json', postgresql.JSONB(astext_type=sa.Text()), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('context_snapshot', postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['rider_id'], ['riders.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.add_column('riders', sa.Column('coaching_profile', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.add_column('riders', sa.Column('onboarding_completed', sa.Boolean(), server_default=sa.text('false'), nullable=False)) + op.add_column('training_plans', sa.Column('status', sa.String(length=20), server_default=sa.text("'active'"), nullable=False)) + op.add_column('training_plans', sa.Column('onboarding_data', postgresql.JSONB(astext_type=sa.Text()), nullable=True)) + op.add_column('training_plans', sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('training_plans', 'updated_at') + op.drop_column('training_plans', 'onboarding_data') + op.drop_column('training_plans', 'status') + op.drop_column('riders', 'onboarding_completed') + op.drop_column('riders', 'coaching_profile') + op.drop_table('coaching_chats') + # ### end Alembic commands ### diff --git a/backend/app/api/activities.py b/backend/app/api/activities.py index 03eacf1..109a8e0 100644 --- a/backend/app/api/activities.py +++ b/backend/app/api/activities.py @@ -5,24 +5,34 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.auth import get_current_rider from backend.app.core.config import settings from backend.app.core.database import get_session -from backend.app.models.activity import Activity, DataPoint +from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval +from backend.app.models.fitness import PowerCurve +from backend.app.models.rider import Rider from backend.app.schemas.activity import ( ActivityResponse, ActivityListResponse, DataPointResponse, + ZonesResponse, + PowerCurveResponse, ) +from backend.app.models.fitness import DiaryEntry from backend.app.services.fit_parser import parse_fit_file from backend.app.services.metrics import calculate_metrics +from backend.app.services.zones import calculate_power_zones, calculate_hr_zones +from backend.app.services.power_curve import calculate_power_curve +from backend.app.services.intervals import detect_intervals +from backend.app.services.ai_summary import generate_summary router = APIRouter() @router.post("/upload", response_model=ActivityResponse) async def upload_activity( - rider_id: uuid.UUID, file: UploadFile = File(...), + rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): if not file.filename or not file.filename.lower().endswith(".fit"): @@ -37,18 +47,33 @@ async def upload_activity( content = await file.read() file_path.write_bytes(content) - activity, data_points = parse_fit_file(content, rider_id, str(file_path)) + # 1. Parse FIT + activity, data_points = parse_fit_file(content, rider.id, str(file_path)) session.add(activity) await session.flush() + # 2. Save data points for dp in data_points: dp.activity_id = activity.id session.add_all(data_points) - metrics = calculate_metrics(data_points, activity, rider_id, session) + # 3. Calculate & save metrics (with FTP if available) + metrics = calculate_metrics(data_points, activity, ftp=rider.ftp) if metrics: session.add(metrics) + # 4. Detect & save intervals + intervals = detect_intervals(data_points, ftp=rider.ftp) + for interval in intervals: + interval.activity_id = activity.id + session.add_all(intervals) + + # 5. Calculate & save power curve + curve_data = calculate_power_curve(data_points) + if curve_data: + pc = PowerCurve(activity_id=activity.id, curve_data=curve_data) + session.add(pc) + await session.commit() await session.refresh(activity) return activity @@ -56,17 +81,17 @@ async def upload_activity( @router.get("", response_model=ActivityListResponse) async def list_activities( - rider_id: uuid.UUID, limit: int = 20, offset: int = 0, + rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): - count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider_id) + count_query = select(func.count(Activity.id)).where(Activity.rider_id == rider.id) total = (await session.execute(count_query)).scalar() or 0 query = ( select(Activity) - .where(Activity.rider_id == rider_id) + .where(Activity.rider_id == rider.id) .order_by(Activity.date.desc()) .limit(limit) .offset(offset) @@ -80,10 +105,11 @@ async def list_activities( @router.get("/{activity_id}", response_model=ActivityResponse) async def get_activity( activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): activity = await session.get(Activity, activity_id) - if not activity: + if not activity or activity.rider_id != rider.id: raise HTTPException(status_code=404, detail="Activity not found") return activity @@ -91,8 +117,14 @@ async def get_activity( @router.get("/{activity_id}/stream", response_model=list[DataPointResponse]) async def get_activity_stream( activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): + # Verify ownership + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + query = ( select(DataPoint) .where(DataPoint.activity_id == activity_id) @@ -100,3 +132,173 @@ async def get_activity_stream( ) result = await session.execute(query) return result.scalars().all() + + +@router.get("/{activity_id}/zones", response_model=ZonesResponse) +async def get_activity_zones( + activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + query = ( + select(DataPoint) + .where(DataPoint.activity_id == activity_id) + .order_by(DataPoint.timestamp) + ) + result = await session.execute(query) + data_points = list(result.scalars().all()) + + power_zones = [] + hr_zones = [] + + if rider.ftp: + power_zones = calculate_power_zones(data_points, rider.ftp) + if rider.lthr: + hr_zones = calculate_hr_zones(data_points, rider.lthr) + + return ZonesResponse(power_zones=power_zones, hr_zones=hr_zones) + + +@router.get("/{activity_id}/power-curve", response_model=PowerCurveResponse) +async def get_activity_power_curve( + activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + # Try cached + query = select(PowerCurve).where(PowerCurve.activity_id == activity_id) + result = await session.execute(query) + pc = result.scalar_one_or_none() + + if pc: + return PowerCurveResponse(curve=pc.curve_data) + + # Calculate on the fly + dp_query = ( + select(DataPoint) + .where(DataPoint.activity_id == activity_id) + .order_by(DataPoint.timestamp) + ) + dp_result = await session.execute(dp_query) + data_points = list(dp_result.scalars().all()) + curve = calculate_power_curve(data_points) + + return PowerCurveResponse(curve=curve) + + +@router.post("/{activity_id}/ai-summary") +async def generate_ai_summary( + activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + # Check for existing diary entry with summary + query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) + result = await session.execute(query) + diary = result.scalar_one_or_none() + + if diary and diary.ai_summary: + return {"summary": diary.ai_summary} + + # Generate new summary + summary = await generate_summary(activity, rider_ftp=rider.ftp) + + if diary: + diary.ai_summary = summary + else: + diary = DiaryEntry(activity_id=activity_id, ai_summary=summary) + session.add(diary) + + await session.commit() + return {"summary": summary} + + +@router.delete("/{activity_id}") +async def delete_activity( + activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + # Delete related records + for model in [DataPoint, Interval, ActivityMetrics, PowerCurve, DiaryEntry]: + q = select(model).where(model.activity_id == activity_id) + result = await session.execute(q) + for row in result.scalars().all(): + await session.delete(row) + + await session.delete(activity) + await session.commit() + return {"ok": True} + + +@router.get("/{activity_id}/diary") +async def get_diary( + activity_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) + result = await session.execute(query) + diary = result.scalar_one_or_none() + + if not diary: + return {"rider_notes": None, "mood": None, "rpe": None, "sleep_hours": None} + + return { + "rider_notes": diary.rider_notes, + "mood": diary.mood, + "rpe": diary.rpe, + "sleep_hours": diary.sleep_hours, + } + + +@router.put("/{activity_id}/diary") +async def update_diary( + activity_id: uuid.UUID, + data: dict, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + activity = await session.get(Activity, activity_id) + if not activity or activity.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Activity not found") + + query = select(DiaryEntry).where(DiaryEntry.activity_id == activity_id) + result = await session.execute(query) + diary = result.scalar_one_or_none() + + if not diary: + diary = DiaryEntry(activity_id=activity_id) + session.add(diary) + + for field in ["rider_notes", "mood", "rpe", "sleep_hours"]: + if field in data: + setattr(diary, field, data[field]) + + await session.commit() + return { + "rider_notes": diary.rider_notes, + "mood": diary.mood, + "rpe": diary.rpe, + "sleep_hours": diary.sleep_hours, + } diff --git a/backend/app/api/coaching.py b/backend/app/api/coaching.py new file mode 100644 index 0000000..31eed10 --- /dev/null +++ b/backend/app/api/coaching.py @@ -0,0 +1,307 @@ +"""Coaching API endpoints — onboarding, chat, plan, compliance.""" + +import uuid + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.core.auth import get_current_rider +from backend.app.core.database import get_session +from backend.app.models.coaching import CoachingChat +from backend.app.models.rider import Rider +from backend.app.models.training import TrainingPlan +from backend.app.services.coaching import ( + process_chat_message, + generate_plan, + get_today_workout, + calculate_compliance, +) + +router = APIRouter() + + +class MessageRequest(BaseModel): + message: str + + +class ChatResponse(BaseModel): + chat_id: str + chat_type: str + status: str + messages: list[dict] + + +class PlanResponse(BaseModel): + id: str + goal: str + start_date: str + end_date: str + phase: str | None + description: str | None + status: str + weeks: list[dict] + + +# --- Onboarding --- + +@router.post("/onboarding/start") +async def start_onboarding( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Start or resume onboarding chat.""" + # Check for existing active onboarding + query = ( + select(CoachingChat) + .where(CoachingChat.rider_id == rider.id) + .where(CoachingChat.chat_type == "onboarding") + .where(CoachingChat.status == "active") + .order_by(CoachingChat.created_at.desc()) + .limit(1) + ) + result = await session.execute(query) + chat = result.scalar_one_or_none() + + if chat: + return { + "chat_id": str(chat.id), + "status": chat.status, + "messages": chat.messages_json or [], + "onboarding_completed": rider.onboarding_completed, + } + + # Create new onboarding chat + chat = CoachingChat( + rider_id=rider.id, + chat_type="onboarding", + status="active", + messages_json=[], + ) + session.add(chat) + await session.commit() + await session.refresh(chat) + + # Send initial greeting by processing an empty-ish message + response = await process_chat_message(rider, chat.id, "Привет! Я хочу начать тренировки.", session) + + return { + "chat_id": str(chat.id), + "status": chat.status, + "messages": chat.messages_json or [], + "onboarding_completed": rider.onboarding_completed, + } + + +@router.get("/onboarding/status") +async def get_onboarding_status( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Check onboarding status.""" + return { + "onboarding_completed": rider.onboarding_completed, + "coaching_profile": rider.coaching_profile, + } + + +# --- Chat --- + +@router.post("/chat/new") +async def create_chat( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Create a new general coaching chat.""" + chat = CoachingChat( + rider_id=rider.id, + chat_type="general", + status="active", + messages_json=[], + ) + session.add(chat) + await session.commit() + await session.refresh(chat) + return {"chat_id": str(chat.id), "status": "active", "messages": []} + + +@router.get("/chats") +async def list_chats( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """List all coaching chats.""" + query = ( + select(CoachingChat) + .where(CoachingChat.rider_id == rider.id) + .order_by(CoachingChat.updated_at.desc()) + .limit(20) + ) + result = await session.execute(query) + chats = result.scalars().all() + return [ + { + "id": str(c.id), + "chat_type": c.chat_type, + "status": c.status, + "message_count": len(c.messages_json or []), + "created_at": c.created_at.isoformat() if c.created_at else None, + "updated_at": c.updated_at.isoformat() if c.updated_at else None, + "last_message": (c.messages_json[-1]["text"][:100] if c.messages_json else None), + } + for c in chats + ] + + +@router.get("/chat/{chat_id}") +async def get_chat( + chat_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Get chat with messages.""" + chat = await session.get(CoachingChat, chat_id) + if not chat or chat.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Chat not found") + return { + "chat_id": str(chat.id), + "chat_type": chat.chat_type, + "status": chat.status, + "messages": chat.messages_json or [], + } + + +@router.post("/chat/{chat_id}/message") +async def send_message( + chat_id: uuid.UUID, + body: MessageRequest, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Send a message to the coaching chat.""" + response = await process_chat_message(rider, chat_id, body.message, session) + + chat = await session.get(CoachingChat, chat_id) + return { + "response": response, + "chat_id": str(chat_id), + "status": chat.status if chat else "active", + "messages": chat.messages_json if chat else [], + "onboarding_completed": rider.onboarding_completed, + } + + +# --- Training Plan --- + +@router.post("/plan/generate") +async def create_plan( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Generate a new AI training plan.""" + plan = await generate_plan(rider, session) + return _plan_to_dict(plan) + + +@router.get("/plan/active") +async def get_active_plan( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Get the active training plan.""" + query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + .order_by(TrainingPlan.created_at.desc()) + .limit(1) + ) + result = await session.execute(query) + plan = result.scalar_one_or_none() + if not plan: + return None + return _plan_to_dict(plan) + + +@router.get("/plan/{plan_id}/compliance") +async def get_plan_compliance( + plan_id: uuid.UUID, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Get compliance data for a plan.""" + plan = await session.get(TrainingPlan, plan_id) + if not plan or plan.rider_id != rider.id: + raise HTTPException(status_code=404, detail="Plan not found") + compliance = await calculate_compliance(plan, session) + return compliance + + +@router.get("/today") +async def get_today( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Get today's planned workout.""" + workout = await get_today_workout(rider, session) + return workout + + +# --- Adjustment chat --- + +@router.post("/plan/adjust") +async def start_plan_adjustment( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Start an adjustment chat for the active plan.""" + # Check active plan exists + plan_query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + .limit(1) + ) + plan_result = await session.execute(plan_query) + plan = plan_result.scalar_one_or_none() + if not plan: + raise HTTPException(status_code=400, detail="No active plan to adjust") + + chat = CoachingChat( + rider_id=rider.id, + chat_type="adjustment", + status="active", + messages_json=[], + ) + session.add(chat) + await session.commit() + await session.refresh(chat) + + # Start with context about what needs adjustment + response = await process_chat_message( + rider, chat.id, + "Мне нужно скорректировать мой текущий план тренировок. Посмотри на мои последние данные и предложи изменения.", + session, + ) + + return { + "chat_id": str(chat.id), + "status": chat.status, + "messages": chat.messages_json or [], + "response": response, + } + + +def _plan_to_dict(plan: TrainingPlan) -> dict: + weeks = plan.weeks_json.get("weeks", []) if plan.weeks_json else [] + return { + "id": str(plan.id), + "goal": plan.goal, + "start_date": plan.start_date.isoformat(), + "end_date": plan.end_date.isoformat(), + "phase": plan.phase, + "description": plan.description, + "status": plan.status, + "weeks": weeks, + } diff --git a/backend/app/api/rider.py b/backend/app/api/rider.py index 3d2a14a..8cde000 100644 --- a/backend/app/api/rider.py +++ b/backend/app/api/rider.py @@ -1,52 +1,146 @@ -import uuid +from datetime import date, timedelta -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends +from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession +from backend.app.core.auth import get_current_rider from backend.app.core.database import get_session +from backend.app.models.activity import Activity, ActivityMetrics +from backend.app.models.fitness import PowerCurve from backend.app.models.rider import Rider -from backend.app.schemas.rider import RiderCreate, RiderUpdate, RiderResponse +from backend.app.schemas.rider import RiderUpdate, RiderResponse, FitnessHistoryResponse +from backend.app.services.fitness import get_fitness_history router = APIRouter() -@router.post("/profile", response_model=RiderResponse) -async def create_rider( - data: RiderCreate, - session: AsyncSession = Depends(get_session), -): - rider = Rider(**data.model_dump()) - session.add(rider) - await session.commit() - await session.refresh(rider) +@router.get("/profile", response_model=RiderResponse) +async def get_rider(rider: Rider = Depends(get_current_rider)): return rider -@router.get("/profile/{rider_id}", response_model=RiderResponse) -async def get_rider( - rider_id: uuid.UUID, - session: AsyncSession = Depends(get_session), -): - rider = await session.get(Rider, rider_id) - if not rider: - raise HTTPException(status_code=404, detail="Rider not found") - return rider - - -@router.put("/profile/{rider_id}", response_model=RiderResponse) +@router.put("/profile", response_model=RiderResponse) async def update_rider( - rider_id: uuid.UUID, data: RiderUpdate, + rider: Rider = Depends(get_current_rider), session: AsyncSession = Depends(get_session), ): - rider = await session.get(Rider, rider_id) - if not rider: - raise HTTPException(status_code=404, detail="Rider not found") - update_data = data.model_dump(exclude_unset=True) + ftp_changed = "ftp" in update_data and update_data["ftp"] != rider.ftp + for key, value in update_data.items(): setattr(rider, key, value) + # Recalculate TSS/IF for all activities when FTP changes + if ftp_changed and rider.ftp: + query = ( + select(ActivityMetrics) + .join(Activity, Activity.id == ActivityMetrics.activity_id) + .where(Activity.rider_id == rider.id) + .where(ActivityMetrics.normalized_power.isnot(None)) + ) + result = await session.execute(query) + for metrics in result.scalars().all(): + np_val = metrics.normalized_power + duration_q = await session.execute( + select(Activity.duration).where(Activity.id == metrics.activity_id) + ) + duration = duration_q.scalar() + if np_val and duration: + metrics.intensity_factor = round(np_val / rider.ftp, 2) + metrics.tss = round( + (duration * np_val * (np_val / rider.ftp)) + / (rider.ftp * 3600) + * 100, + 1, + ) + await session.commit() await session.refresh(rider) return rider + + +@router.get("/fitness", response_model=list[FitnessHistoryResponse]) +async def get_fitness( + days: int = 90, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + entries = await get_fitness_history(rider.id, session, days=days) + return entries + + +@router.get("/weekly-stats") +async def get_weekly_stats( + weeks: int = 8, + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + today = date.today() + # Start from Monday of current week + start_of_week = today - timedelta(days=today.weekday()) + start_date = start_of_week - timedelta(weeks=weeks - 1) + + week_col = func.date_trunc('week', Activity.date).label("week") + + query = ( + select( + week_col, + func.count(Activity.id).label("rides"), + func.sum(Activity.duration).label("duration"), + func.sum(Activity.distance).label("distance"), + func.sum(ActivityMetrics.tss).label("tss"), + ) + .select_from(Activity) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= start_date) + .group_by(week_col) + .order_by(week_col) + ) + + result = await session.execute(query) + + return [ + { + "week": row.week.strftime("%Y-%m-%d") if row.week else None, + "rides": row.rides, + "duration": row.duration or 0, + "distance": round(float(row.distance or 0) / 1000, 1), + "tss": round(float(row.tss or 0), 0), + } + for row in result + ] + + +@router.get("/personal-records") +async def get_personal_records( + rider: Rider = Depends(get_current_rider), + session: AsyncSession = Depends(get_session), +): + """Best power at each standard duration across all activities.""" + query = ( + select(PowerCurve.curve_data, Activity.date, Activity.name, Activity.id) + .join(Activity, Activity.id == PowerCurve.activity_id) + .where(Activity.rider_id == rider.id) + ) + result = await session.execute(query) + + # Merge all curves: keep best power + source activity for each duration + records: dict[int, dict] = {} + for row in result: + curve_data = row.curve_data + for dur_str, power in curve_data.items(): + dur = int(dur_str) + if dur not in records or power > records[dur]["power"]: + records[dur] = { + "duration": dur, + "power": power, + "activity_id": str(row.id), + "activity_name": row.name or "Ride", + "date": row.date.isoformat(), + } + + # Sort by duration + return sorted(records.values(), key=lambda r: r["duration"]) diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 7a83694..fce9b23 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -3,9 +3,11 @@ from fastapi import APIRouter from backend.app.api.auth import router as auth_router from backend.app.api.activities import router as activities_router from backend.app.api.rider import router as rider_router +from backend.app.api.coaching import router as coaching_router api_router = APIRouter(prefix="/api") api_router.include_router(auth_router, prefix="/auth", tags=["auth"]) api_router.include_router(activities_router, prefix="/activities", tags=["activities"]) api_router.include_router(rider_router, prefix="/rider", tags=["rider"]) +api_router.include_router(coaching_router, prefix="/coaching", tags=["coaching"]) diff --git a/backend/app/bot.py b/backend/app/bot.py new file mode 100644 index 0000000..e64f4b3 --- /dev/null +++ b/backend/app/bot.py @@ -0,0 +1,186 @@ +"""Telegram bot for uploading .FIT files.""" + +import logging +import uuid +from pathlib import Path + +from aiogram import Bot, Dispatcher, Router, F +from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, WebAppInfo +from aiogram.filters import CommandStart +from sqlalchemy import select + +from backend.app.core.config import settings +from backend.app.core.database import async_session +from backend.app.models.rider import Rider +from backend.app.models.activity import Activity +from backend.app.models.fitness import PowerCurve +from backend.app.services.fit_parser import parse_fit_file +from backend.app.services.metrics import calculate_metrics +from backend.app.services.intervals import detect_intervals +from backend.app.services.power_curve import calculate_power_curve + +logger = logging.getLogger(__name__) + +router = Router() + + +async def get_or_create_rider(telegram_id: int, name: str, username: str | None) -> Rider: + """Find rider by telegram_id or create a new one.""" + async with async_session() as session: + query = select(Rider).where(Rider.telegram_id == telegram_id) + result = await session.execute(query) + rider = result.scalar_one_or_none() + + if not rider: + rider = Rider( + telegram_id=telegram_id, + telegram_username=username, + name=name, + ) + session.add(rider) + await session.commit() + await session.refresh(rider) + + return rider + + +async def process_fit_upload(content: bytes, rider: Rider) -> Activity: + """Parse FIT file and save activity with all related data.""" + upload_dir = Path(settings.UPLOAD_DIR) + upload_dir.mkdir(parents=True, exist_ok=True) + + file_id = uuid.uuid4() + file_path = upload_dir / f"{file_id}.fit" + file_path.write_bytes(content) + + async with async_session() as session: + # Re-attach rider to this session + rider = await session.get(Rider, rider.id) + + activity, data_points = parse_fit_file(content, rider.id, str(file_path)) + session.add(activity) + await session.flush() + + for dp in data_points: + dp.activity_id = activity.id + session.add_all(data_points) + + metrics = calculate_metrics(data_points, activity, ftp=rider.ftp) + if metrics: + session.add(metrics) + + intervals = detect_intervals(data_points, ftp=rider.ftp) + for interval in intervals: + interval.activity_id = activity.id + session.add_all(intervals) + + curve_data = calculate_power_curve(data_points) + if curve_data: + pc = PowerCurve(activity_id=activity.id, curve_data=curve_data) + session.add(pc) + + await session.commit() + await session.refresh(activity) + return activity + + +def format_duration(seconds: int) -> str: + h = seconds // 3600 + m = (seconds % 3600) // 60 + return f"{h}h {m}m" if h > 0 else f"{m}m" + + +@router.message(CommandStart()) +async def cmd_start(message: Message): + user = message.from_user + await get_or_create_rider( + telegram_id=user.id, + name=user.full_name, + username=user.username, + ) + await message.answer( + f"Welcome to VeloBrain, {user.first_name}!\n\n" + "Send me a .FIT file from your bike computer and I'll analyze your ride.\n\n" + "I'll calculate:\n" + "- Power metrics (NP, TSS, IF)\n" + "- Power zones & HR zones\n" + "- Power curve\n" + "- Interval detection\n\n" + "Just drag & drop your .FIT file here!" + ) + + +@router.message(F.document) +async def handle_document(message: Message, bot: Bot): + doc = message.document + if not doc.file_name or not doc.file_name.lower().endswith(".fit"): + await message.answer("Please send a .FIT file.") + return + + user = message.from_user + rider = await get_or_create_rider( + telegram_id=user.id, + name=user.full_name, + username=user.username, + ) + + status_msg = await message.answer("Processing your .FIT file...") + + try: + file = await bot.download(doc) + content = file.read() + + activity = await process_fit_upload(content, rider) + + m = activity.metrics + lines = [ + f"*{activity.name or 'Ride'}*", + f"Duration: {format_duration(activity.duration)}", + ] + + if activity.distance: + lines.append(f"Distance: {activity.distance / 1000:.1f} km") + if activity.elevation_gain: + lines.append(f"Elevation: {activity.elevation_gain:.0f} m") + + if m: + if m.avg_power: + lines.append(f"Avg Power: {m.avg_power:.0f} W") + if m.normalized_power: + lines.append(f"NP: {m.normalized_power:.0f} W") + if m.tss: + lines.append(f"TSS: {m.tss:.0f}") + if m.intensity_factor: + lines.append(f"IF: {m.intensity_factor:.2f}") + if m.avg_hr: + lines.append(f"Avg HR: {m.avg_hr} bpm") + if m.avg_cadence: + lines.append(f"Avg Cadence: {m.avg_cadence} rpm") + + intervals_count = len(activity.intervals or []) + if intervals_count > 0: + work = [i for i in activity.intervals if i.interval_type == "work"] + lines.append(f"Intervals: {len(work)} work / {intervals_count - len(work)} rest") + + lines.append(f"\nView details in the web app!") + keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Открыть в WebApp", web_app=WebAppInfo(url=f"https://sport.luminic.space//activities/{activity.id}"))]]) + await status_msg.edit_text("\n".join(lines), parse_mode="Markdown", reply_markup=keyboard) + + except Exception as e: + logger.exception("Error processing FIT file") + await status_msg.edit_text(f"Error processing file: {str(e)}") + + +@router.message() +async def handle_other(message: Message): + await message.answer( + "Send me a .FIT file to analyze your ride!\n" + "Use /start to see what I can do." + ) + + +def create_bot() -> tuple[Bot, Dispatcher]: + bot = Bot(token=settings.TELEGRAM_BOT_TOKEN) + dp = Dispatcher() + dp.include_router(router) + return bot, dp diff --git a/backend/app/main.py b/backend/app/main.py index e68d177..2e417c9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,5 @@ +import asyncio +import logging from contextlib import asynccontextmanager from fastapi import FastAPI @@ -6,12 +8,29 @@ from fastapi.middleware.cors import CORSMiddleware from backend.app.api.router import api_router from backend.app.core.config import settings +logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI): - # Startup + # Start Telegram bot polling in background + bot_task = None + if settings.TELEGRAM_BOT_TOKEN: + from backend.app.bot import create_bot + bot, dp = create_bot() + bot_task = asyncio.create_task(dp.start_polling(bot)) + logger.info("Telegram bot polling started") + yield + # Shutdown + if bot_task: + bot_task.cancel() + try: + await bot_task + except asyncio.CancelledError: + pass + logger.info("Telegram bot polling stopped") app = FastAPI( diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index d72ea56..5653510 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from backend.app.models.rider import Rider from backend.app.models.activity import Activity, ActivityMetrics, DataPoint, Interval from backend.app.models.fitness import FitnessHistory, PowerCurve, DiaryEntry from backend.app.models.training import TrainingPlan +from backend.app.models.coaching import CoachingChat __all__ = [ "Rider", @@ -13,4 +14,5 @@ __all__ = [ "PowerCurve", "DiaryEntry", "TrainingPlan", + "CoachingChat", ] diff --git a/backend/app/models/coaching.py b/backend/app/models/coaching.py new file mode 100644 index 0000000..5de372b --- /dev/null +++ b/backend/app/models/coaching.py @@ -0,0 +1,22 @@ +import uuid +from datetime import datetime + +from sqlalchemy import String, DateTime, ForeignKey, func +from sqlalchemy.dialects.postgresql import UUID, JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from backend.app.core.database import Base + + +class CoachingChat(Base): + __tablename__ = "coaching_chats" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + rider_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("riders.id")) + chat_type: Mapped[str] = mapped_column(String(50)) # onboarding | general | adjustment + messages_json: Mapped[list] = mapped_column(JSONB, default=list) # [{role, text, timestamp}] + status: Mapped[str] = mapped_column(String(20), default="active") # active | completed + context_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/rider.py b/backend/app/models/rider.py index 3e28a5a..c2a397a 100644 --- a/backend/app/models/rider.py +++ b/backend/app/models/rider.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime -from sqlalchemy import String, Float, BigInteger, DateTime, func +from sqlalchemy import String, Float, Boolean, BigInteger, DateTime, func from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -22,6 +22,8 @@ class Rider(Base): zones_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) goals: Mapped[str | None] = mapped_column(String(500), nullable=True) experience_level: Mapped[str | None] = mapped_column(String(50), nullable=True) + coaching_profile: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + onboarding_completed: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/models/training.py b/backend/app/models/training.py index 60dedf6..5d5fa53 100644 --- a/backend/app/models/training.py +++ b/backend/app/models/training.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from sqlalchemy import String, Date, DateTime, ForeignKey, Text, func +from sqlalchemy import String, Boolean, Date, DateTime, ForeignKey, Text, func from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -19,5 +19,8 @@ class TrainingPlan(Base): phase: Mapped[str | None] = mapped_column(String(50), nullable=True) weeks_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True) description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), default="active") # draft | active | completed | cancelled + onboarding_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) diff --git a/backend/app/schemas/activity.py b/backend/app/schemas/activity.py index 2f66b43..c3a83ad 100644 --- a/backend/app/schemas/activity.py +++ b/backend/app/schemas/activity.py @@ -17,6 +17,19 @@ class ActivityMetricsResponse(BaseModel): max_hr: int | None = None avg_cadence: int | None = None avg_speed: float | None = None + calories: int | None = None + + +class IntervalResponse(BaseModel): + model_config = {"from_attributes": True} + + id: UUID + start_ts: datetime + end_ts: datetime + interval_type: str + avg_power: float | None = None + avg_hr: int | None = None + duration: int | None = None class ActivityResponse(BaseModel): @@ -31,6 +44,7 @@ class ActivityResponse(BaseModel): distance: float | None = None elevation_gain: float | None = None metrics: ActivityMetricsResponse | None = None + intervals: list[IntervalResponse] = [] class ActivityListResponse(BaseModel): @@ -50,3 +64,29 @@ class DataPointResponse(BaseModel): longitude: float | None = None altitude: float | None = None temperature: int | None = None + + +class ZoneItem(BaseModel): + zone: int + name: str + seconds: int + percentage: float + + +class PowerZoneItem(ZoneItem): + min_watts: int + max_watts: int | None = None + + +class HrZoneItem(ZoneItem): + min_bpm: int + max_bpm: int | None = None + + +class ZonesResponse(BaseModel): + power_zones: list[PowerZoneItem] = [] + hr_zones: list[HrZoneItem] = [] + + +class PowerCurveResponse(BaseModel): + curve: dict[int, int] # {duration_seconds: max_power} diff --git a/backend/app/schemas/rider.py b/backend/app/schemas/rider.py index 58f05af..0b80f71 100644 --- a/backend/app/schemas/rider.py +++ b/backend/app/schemas/rider.py @@ -1,3 +1,4 @@ +from datetime import date from uuid import UUID from pydantic import BaseModel @@ -22,6 +23,16 @@ class RiderUpdate(BaseModel): experience_level: str | None = None +class FitnessHistoryResponse(BaseModel): + model_config = {"from_attributes": True} + + date: date + ctl: float + atl: float + tsb: float + ramp_rate: float | None = None + + class RiderResponse(BaseModel): model_config = {"from_attributes": True} @@ -36,3 +47,5 @@ class RiderResponse(BaseModel): zones_config: dict | None = None goals: str | None = None experience_level: str | None = None + coaching_profile: dict | None = None + onboarding_completed: bool = False diff --git a/backend/app/services/ai_summary.py b/backend/app/services/ai_summary.py new file mode 100644 index 0000000..afc9bc5 --- /dev/null +++ b/backend/app/services/ai_summary.py @@ -0,0 +1,62 @@ +"""Generate AI activity summaries using Gemini.""" + +from backend.app.models.activity import Activity, ActivityMetrics +from backend.app.services.gemini_client import chat_async + +SYSTEM_PROMPT = """You are VeloBrain, an AI cycling coach. +Analyze the cycling activity data and provide a concise, insightful summary in 3-5 sentences. +Focus on: performance highlights, pacing strategy, areas for improvement, and training effect. +Be specific with numbers. Use a friendly, coaching tone. +Respond in Russian. +Use a HTML text formatting.""" + + +def _build_activity_prompt(activity: Activity, rider_ftp: float | None = None) -> str: + m: ActivityMetrics | None = activity.metrics + lines = [ + f"Activity: {activity.name or 'Ride'}", + f"Type: {activity.activity_type}", + f"Duration: {activity.duration // 3600}h {(activity.duration % 3600) // 60}m", + ] + + if activity.distance: + lines.append(f"Distance: {activity.distance / 1000:.1f} km") + if activity.elevation_gain: + lines.append(f"Elevation: {activity.elevation_gain:.0f} m") + + if m: + if m.avg_power: + lines.append(f"Avg Power: {m.avg_power:.0f} W") + if m.normalized_power: + lines.append(f"Normalized Power: {m.normalized_power:.0f} W") + if m.tss: + lines.append(f"TSS: {m.tss:.0f}") + if m.intensity_factor: + lines.append(f"IF: {m.intensity_factor:.2f}") + if m.variability_index: + lines.append(f"VI: {m.variability_index:.2f}") + if m.avg_hr: + lines.append(f"Avg HR: {m.avg_hr} bpm") + if m.max_hr: + lines.append(f"Max HR: {m.max_hr} bpm") + if m.avg_cadence: + lines.append(f"Avg Cadence: {m.avg_cadence} rpm") + + if rider_ftp: + lines.append(f"Rider FTP: {rider_ftp:.0f} W") + + intervals = activity.intervals or [] + work_intervals = [i for i in intervals if i.interval_type == "work"] + if work_intervals: + lines.append(f"Work intervals: {len(work_intervals)}") + powers = [i.avg_power for i in work_intervals if i.avg_power] + if powers: + lines.append(f"Interval avg powers: {', '.join(f'{p:.0f}W' for p in powers)}") + + return "\n".join(lines) + + +async def generate_summary(activity: Activity, rider_ftp: float | None = None) -> str: + prompt = _build_activity_prompt(activity, rider_ftp) + messages = [{"role": "user", "text": prompt}] + return await chat_async(messages, system_instruction=SYSTEM_PROMPT, temperature=0.5) diff --git a/backend/app/services/coaching.py b/backend/app/services/coaching.py new file mode 100644 index 0000000..b0a23f8 --- /dev/null +++ b/backend/app/services/coaching.py @@ -0,0 +1,461 @@ +"""AI Coaching service — onboarding, plan generation, chat.""" + +import json +import re +from datetime import date, datetime, timedelta + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.activity import Activity, ActivityMetrics +from backend.app.models.coaching import CoachingChat +from backend.app.models.fitness import FitnessHistory, PowerCurve +from backend.app.models.rider import Rider +from backend.app.models.training import TrainingPlan +from backend.app.services.gemini_client import chat_async + + +ONBOARDING_SYSTEM = """You are VeloBrain AI Coach — a professional cycling coach. +You are conducting an onboarding interview with a new athlete. + +Ask questions ONE AT A TIME, in a friendly conversational tone. +Keep responses short (2-3 sentences + question). + +Questions to cover (in rough order): +1. Main cycling goal (fitness, racing, gran fondo, weight loss, etc.) +2. Target event/race (if any) and its date +3. Current weekly training volume (hours/week) +4. How many days per week they can train +5. Which days are available for training +6. Indoor trainer availability (smart trainer, basic, none) +7. Power meter availability +8. Any injuries or health concerns +9. Previous coaching or structured training experience +10. What they enjoy most about cycling + +When ALL questions are answered, respond with your summary and then output EXACTLY this marker on a new line: +[ONBOARDING_COMPLETE] +Followed by a JSON block with the structured data: +```json +{ + "goal": "...", + "target_event": "...", + "target_event_date": "...", + "hours_per_week": N, + "days_per_week": N, + "available_days": ["monday", ...], + "has_indoor_trainer": true/false, + "trainer_type": "smart/basic/none", + "has_power_meter": true/false, + "injuries": "...", + "coaching_experience": "...", + "enjoys": "..." +} +``` + +Respond in Russian.""" + + +PLAN_GENERATION_SYSTEM = """You are VeloBrain AI Coach generating a structured training plan. +Based on the rider's profile, current fitness, and goals, create a detailed multi-week training plan. + +Output ONLY a valid JSON block with this structure: +```json +{ + "goal": "short goal description", + "description": "plan overview in 2-3 sentences", + "phase": "base/build/peak/recovery", + "duration_weeks": N, + "weeks": [ + { + "week_number": 1, + "focus": "week focus description", + "target_tss": 300, + "target_hours": 8, + "days": [ + { + "day": "monday", + "workout_type": "rest", + "title": "Rest Day", + "description": "", + "duration_minutes": 0, + "target_tss": 0, + "target_if": 0 + } + ] + } + ] +} +``` + +workout_type options: rest, endurance, tempo, sweetspot, threshold, vo2max, sprint, recovery, race +Plan duration: 4-8 weeks based on goal. +Include progressive overload with recovery weeks every 3-4 weeks. +Adjust intensity based on rider's FTP and experience level. +All descriptions in Russian.""" + + +ADJUSTMENT_SYSTEM = """You are VeloBrain AI Coach reviewing a training plan. +The rider's plan needs adjustment based on their recent performance, compliance, and fatigue. + +Analyze the data provided and suggest specific changes to upcoming weeks. +When you've decided on adjustments, output: +[PLAN_ADJUSTED] +Followed by the updated weeks JSON. + +Respond in Russian.""" + + +GENERAL_CHAT_SYSTEM = """You are VeloBrain AI Coach — a knowledgeable and supportive cycling coach. +You have access to the rider's training data and can answer questions about: +- Training methodology and periodization +- Nutrition and recovery +- Equipment and bike fit +- Race strategy and pacing +- Interpreting their power/HR data + +Be concise, specific, and actionable. Use the rider's actual data when relevant. +Respond in Russian.""" + + +async def build_rider_context(rider: Rider, session: AsyncSession) -> str: + """Build a concise context string with rider's current state.""" + lines = [ + f"Rider: {rider.name}", + f"FTP: {rider.ftp or 'not set'} W", + f"Weight: {rider.weight or 'not set'} kg", + f"LTHR: {rider.lthr or 'not set'} bpm", + f"Experience: {rider.experience_level or 'not set'}", + f"Goals: {rider.goals or 'not set'}", + ] + + if rider.ftp and rider.weight: + lines.append(f"W/kg: {rider.ftp / rider.weight:.2f}") + + # Coaching profile + if rider.coaching_profile: + cp = rider.coaching_profile + lines.append(f"\nCoaching Profile:") + for k, v in cp.items(): + lines.append(f" {k}: {v}") + + # Fitness (latest CTL/ATL/TSB) + fh_query = ( + select(FitnessHistory) + .where(FitnessHistory.rider_id == rider.id) + .order_by(FitnessHistory.date.desc()) + .limit(1) + ) + fh_result = await session.execute(fh_query) + fh = fh_result.scalar_one_or_none() + if fh: + lines.append(f"\nFitness: CTL={fh.ctl:.0f} ATL={fh.atl:.0f} TSB={fh.tsb:.0f}") + + # Recent 4 weeks volume + four_weeks_ago = date.today() - timedelta(weeks=4) + vol_query = ( + select( + Activity.date, + Activity.duration, + ActivityMetrics.tss, + ) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider.id) + .where(Activity.date >= four_weeks_ago) + .order_by(Activity.date.desc()) + ) + vol_result = await session.execute(vol_query) + rides = list(vol_result.all()) + if rides: + total_hours = sum(r.duration for r in rides) / 3600 + total_tss = sum(float(r.tss or 0) for r in rides) + lines.append(f"\nLast 4 weeks: {len(rides)} rides, {total_hours:.1f}h, TSS={total_tss:.0f}") + lines.append(f"Avg/week: {total_hours / 4:.1f}h, TSS={total_tss / 4:.0f}") + + # Personal records (from power curves) + pc_query = ( + select(PowerCurve.curve_data) + .join(Activity, Activity.id == PowerCurve.activity_id) + .where(Activity.rider_id == rider.id) + ) + pc_result = await session.execute(pc_query) + best: dict[int, int] = {} + for row in pc_result: + for dur_str, power in row.curve_data.items(): + dur = int(dur_str) + if dur not in best or power > best[dur]: + best[dur] = power + if best: + pr_strs = [] + for dur in sorted(best.keys()): + if dur < 60: + pr_strs.append(f"{dur}s={best[dur]}W") + elif dur < 3600: + pr_strs.append(f"{dur // 60}m={best[dur]}W") + else: + pr_strs.append(f"{dur // 3600}h={best[dur]}W") + lines.append(f"\nPower PRs: {', '.join(pr_strs)}") + + # Active plan status + plan_query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + .order_by(TrainingPlan.created_at.desc()) + .limit(1) + ) + plan_result = await session.execute(plan_query) + plan = plan_result.scalar_one_or_none() + if plan: + lines.append(f"\nActive plan: '{plan.goal}' ({plan.start_date} to {plan.end_date}), phase: {plan.phase}") + + return "\n".join(lines) + + +async def process_chat_message( + rider: Rider, + chat_id, + user_message: str, + session: AsyncSession, +) -> str: + """Process a user message in a coaching chat and return AI response.""" + chat = await session.get(CoachingChat, chat_id) + if not chat or chat.rider_id != rider.id: + raise ValueError("Chat not found") + + # Build context + rider_context = await build_rider_context(rider, session) + + # Select system prompt + system_prompts = { + "onboarding": ONBOARDING_SYSTEM, + "general": GENERAL_CHAT_SYSTEM, + "adjustment": ADJUSTMENT_SYSTEM, + } + system = system_prompts.get(chat.chat_type, GENERAL_CHAT_SYSTEM) + system = f"{system}\n\n--- Rider Data ---\n{rider_context}" + + # Build message history + messages = list(chat.messages_json or []) + messages.append({"role": "user", "text": user_message}) + + # Call Gemini + gemini_messages = [{"role": m["role"], "text": m["text"]} for m in messages] + response = await chat_async(gemini_messages, system_instruction=system, temperature=0.7) + + # Save messages + now = datetime.utcnow().isoformat() + messages_to_save = list(chat.messages_json or []) + messages_to_save.append({"role": "user", "text": user_message, "timestamp": now}) + messages_to_save.append({"role": "model", "text": response, "timestamp": now}) + chat.messages_json = messages_to_save + + # Check for onboarding completion + if chat.chat_type == "onboarding" and "[ONBOARDING_COMPLETE]" in response: + chat.status = "completed" + profile_data = _extract_json(response) + if profile_data: + rider.coaching_profile = profile_data + rider.onboarding_completed = True + if profile_data.get("goal"): + rider.goals = profile_data["goal"] + + # Check for plan adjustment + if chat.chat_type == "adjustment" and "[PLAN_ADJUSTED]" in response: + chat.status = "completed" + plan_data = _extract_json(response) + if plan_data: + plan_query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + .order_by(TrainingPlan.created_at.desc()) + .limit(1) + ) + plan_result = await session.execute(plan_query) + plan = plan_result.scalar_one_or_none() + if plan and "weeks" in plan_data: + current = plan.weeks_json or {} + current["weeks"] = plan_data["weeks"] + plan.weeks_json = current + + await session.commit() + return response + + +async def generate_plan(rider: Rider, session: AsyncSession) -> TrainingPlan: + """Generate a new training plan using AI.""" + rider_context = await build_rider_context(rider, session) + + prompt = f"Generate a training plan for this rider.\n\n{rider_context}" + messages = [{"role": "user", "text": prompt}] + + response = await chat_async( + messages, + system_instruction=PLAN_GENERATION_SYSTEM, + temperature=0.5, + ) + + plan_data = _extract_json(response) + if not plan_data or "weeks" not in plan_data: + raise ValueError("Failed to parse plan from AI response") + + # Cancel existing active plans + existing_query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + ) + existing_result = await session.execute(existing_query) + for old_plan in existing_result.scalars().all(): + old_plan.status = "cancelled" + + duration_weeks = plan_data.get("duration_weeks", len(plan_data["weeks"])) + start = date.today() + # Align to next Monday + days_until_monday = (7 - start.weekday()) % 7 + if days_until_monday == 0: + days_until_monday = 0 + start = start + timedelta(days=days_until_monday) + + plan = TrainingPlan( + rider_id=rider.id, + goal=plan_data.get("goal", rider.goals or "General fitness"), + start_date=start, + end_date=start + timedelta(weeks=duration_weeks), + phase=plan_data.get("phase", "base"), + weeks_json=plan_data, + description=plan_data.get("description", ""), + status="active", + onboarding_data=rider.coaching_profile, + ) + session.add(plan) + await session.commit() + await session.refresh(plan) + return plan + + +async def get_today_workout(rider: Rider, session: AsyncSession) -> dict | None: + """Get today's planned workout from the active plan.""" + plan_query = ( + select(TrainingPlan) + .where(TrainingPlan.rider_id == rider.id) + .where(TrainingPlan.status == "active") + .order_by(TrainingPlan.created_at.desc()) + .limit(1) + ) + result = await session.execute(plan_query) + plan = result.scalar_one_or_none() + if not plan or not plan.weeks_json: + return None + + today = date.today() + if today < plan.start_date or today > plan.end_date: + return None + + week_num = (today - plan.start_date).days // 7 + 1 + day_name = today.strftime("%A").lower() + + weeks = plan.weeks_json.get("weeks", []) + for week in weeks: + if week.get("week_number") == week_num: + for day in week.get("days", []): + if day.get("day") == day_name: + return { + "plan_id": str(plan.id), + "plan_goal": plan.goal, + "week_number": week_num, + "week_focus": week.get("focus", ""), + **day, + } + + return None + + +async def calculate_compliance(plan: TrainingPlan, session: AsyncSession) -> list[dict]: + """Compare planned vs actual per week.""" + if not plan.weeks_json: + return [] + + weeks = plan.weeks_json.get("weeks", []) + results = [] + + for week in weeks: + week_num = week.get("week_number", 0) + week_start = plan.start_date + timedelta(weeks=week_num - 1) + week_end = week_start + timedelta(days=7) + + # Skip future weeks + if week_start > date.today(): + results.append({ + "week_number": week_num, + "focus": week.get("focus", ""), + "planned_tss": week.get("target_tss", 0), + "actual_tss": 0, + "planned_hours": week.get("target_hours", 0), + "actual_hours": 0, + "planned_rides": sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest"), + "actual_rides": 0, + "adherence_pct": 0, + "status": "upcoming", + }) + continue + + # Get actual activities in this week + act_query = ( + select(Activity, ActivityMetrics.tss) + .outerjoin(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == plan.rider_id) + .where(Activity.date >= week_start) + .where(Activity.date < week_end) + ) + act_result = await session.execute(act_query) + acts = list(act_result.all()) + + actual_tss = sum(float(r.tss or 0) for r in acts) + actual_hours = sum(r[0].duration for r in acts) / 3600 + actual_rides = len(acts) + planned_rides = sum(1 for d in week.get("days", []) if d.get("workout_type") != "rest") + planned_tss = week.get("target_tss", 0) + + adherence = 0 + if planned_rides > 0: + adherence = min(100, round(actual_rides / planned_rides * 100)) + + is_current = week_start <= date.today() < week_end + + results.append({ + "week_number": week_num, + "focus": week.get("focus", ""), + "planned_tss": planned_tss, + "actual_tss": round(actual_tss, 0), + "planned_hours": week.get("target_hours", 0), + "actual_hours": round(actual_hours, 1), + "planned_rides": planned_rides, + "actual_rides": actual_rides, + "adherence_pct": adherence, + "status": "current" if is_current else "completed", + }) + + return results + + +def _extract_json(text: str) -> dict | None: + """Extract JSON from AI response text.""" + # Try to find JSON in code blocks + match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", text, re.DOTALL) + if match: + try: + return json.loads(match.group(1)) + except json.JSONDecodeError: + pass + + # Try to find raw JSON object + match = re.search(r"\{[\s\S]*\}", text) + if match: + try: + return json.loads(match.group(0)) + except json.JSONDecodeError: + pass + + return None diff --git a/backend/app/services/fitness.py b/backend/app/services/fitness.py new file mode 100644 index 0000000..d60b56d --- /dev/null +++ b/backend/app/services/fitness.py @@ -0,0 +1,107 @@ +"""CTL / ATL / TSB (Fitness / Fatigue / Form) calculation service.""" + +from datetime import date, timedelta + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.activity import Activity, ActivityMetrics +from backend.app.models.fitness import FitnessHistory + +CTL_DAYS = 42 # Chronic Training Load time constant +ATL_DAYS = 7 # Acute Training Load time constant + + +async def rebuild_fitness_history( + rider_id, + session: AsyncSession, + days_back: int = 365, +) -> list[FitnessHistory]: + """Rebuild CTL/ATL/TSB for a rider from scratch using exponential moving averages.""" + + end_date = date.today() + start_date = end_date - timedelta(days=days_back) + + # Get all activities with TSS in the date range + query = ( + select( + func.date(Activity.date).label("activity_date"), + func.sum(ActivityMetrics.tss).label("daily_tss"), + ) + .join(ActivityMetrics, ActivityMetrics.activity_id == Activity.id) + .where(Activity.rider_id == rider_id) + .where(Activity.date >= start_date) + .where(ActivityMetrics.tss.isnot(None)) + .group_by(func.date(Activity.date)) + .order_by(func.date(Activity.date)) + ) + + result = await session.execute(query) + tss_by_date: dict[date, float] = {} + for row in result: + tss_by_date[row.activity_date] = float(row.daily_tss) + + # Delete existing history for this rider + existing = await session.execute( + select(FitnessHistory).where(FitnessHistory.rider_id == rider_id) + ) + for entry in existing.scalars().all(): + await session.delete(entry) + + # Calculate EMA-based CTL/ATL/TSB + ctl = 0.0 + atl = 0.0 + prev_ctl = 0.0 + entries: list[FitnessHistory] = [] + + current = start_date + while current <= end_date: + daily_tss = tss_by_date.get(current, 0.0) + + ctl = ctl + (daily_tss - ctl) / CTL_DAYS + atl = atl + (daily_tss - atl) / ATL_DAYS + tsb = ctl - atl + ramp_rate = ctl - prev_ctl + + entry = FitnessHistory( + rider_id=rider_id, + date=current, + ctl=round(ctl, 1), + atl=round(atl, 1), + tsb=round(tsb, 1), + ramp_rate=round(ramp_rate, 2), + ) + entries.append(entry) + + prev_ctl = ctl + current += timedelta(days=1) + + session.add_all(entries) + await session.flush() + return entries + + +async def get_fitness_history( + rider_id, + session: AsyncSession, + days: int = 90, +) -> list[FitnessHistory]: + """Get fitness history for a rider, rebuilding if needed.""" + + cutoff = date.today() - timedelta(days=days) + + query = ( + select(FitnessHistory) + .where(FitnessHistory.rider_id == rider_id) + .where(FitnessHistory.date >= cutoff) + .order_by(FitnessHistory.date) + ) + result = await session.execute(query) + entries = list(result.scalars().all()) + + # If no data or stale, rebuild + if not entries or entries[-1].date < date.today() - timedelta(days=1): + all_entries = await rebuild_fitness_history(rider_id, session) + entries = [e for e in all_entries if e.date >= cutoff] + + return entries diff --git a/backend/app/services/intervals.py b/backend/app/services/intervals.py new file mode 100644 index 0000000..b1c2df1 --- /dev/null +++ b/backend/app/services/intervals.py @@ -0,0 +1,88 @@ +import numpy as np + +from backend.app.models.activity import DataPoint, Interval + + +def detect_intervals( + data_points: list[DataPoint], + ftp: float | None = None, + min_duration: int = 30, +) -> list[Interval]: + """ + Auto-detect work/rest intervals based on power thresholds. + Work = power >= 88% FTP (sweetspot and above) + Rest = power < 55% FTP (active recovery) + """ + powers = [dp.power for dp in data_points] + if not any(p is not None for p in powers): + return [] + + if not ftp or ftp <= 0: + # Without FTP, use median power as threshold + valid_powers = [p for p in powers if p is not None and p > 0] + if not valid_powers: + return [] + threshold_high = np.median(valid_powers) * 1.15 + threshold_low = np.median(valid_powers) * 0.65 + else: + threshold_high = ftp * 0.88 + threshold_low = ftp * 0.55 + + intervals: list[Interval] = [] + current_type: str | None = None + start_idx: int = 0 + + for i, dp in enumerate(data_points): + p = dp.power if dp.power is not None else 0 + if p >= threshold_high: + new_type = "work" + elif p < threshold_low: + new_type = "rest" + else: + continue # tempo zone — don't break interval + + if current_type is None: + current_type = new_type + start_idx = i + elif new_type != current_type: + interval = _build_interval(data_points, start_idx, i - 1, current_type, min_duration) + if interval: + intervals.append(interval) + current_type = new_type + start_idx = i + + # Close last interval + if current_type is not None: + interval = _build_interval(data_points, start_idx, len(data_points) - 1, current_type, min_duration) + if interval: + intervals.append(interval) + + return intervals + + +def _build_interval( + data_points: list[DataPoint], + start_idx: int, + end_idx: int, + interval_type: str, + min_duration: int, +) -> Interval | None: + segment = data_points[start_idx:end_idx + 1] + if len(segment) < min_duration: + return None + + powers = [dp.power for dp in segment if dp.power is not None] + hrs = [dp.heart_rate for dp in segment if dp.heart_rate is not None] + + duration = int((segment[-1].timestamp - segment[0].timestamp).total_seconds()) + if duration < min_duration: + return None + + return Interval( + start_ts=segment[0].timestamp, + end_ts=segment[-1].timestamp, + interval_type=interval_type, + avg_power=round(sum(powers) / len(powers), 1) if powers else None, + avg_hr=round(sum(hrs) / len(hrs)) if hrs else None, + duration=duration, + ) diff --git a/backend/app/services/metrics.py b/backend/app/services/metrics.py index b61a156..c862f61 100644 --- a/backend/app/services/metrics.py +++ b/backend/app/services/metrics.py @@ -1,20 +1,14 @@ -import uuid - import numpy as np -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.activity import Activity, ActivityMetrics, DataPoint -from backend.app.models.rider import Rider def calculate_metrics( data_points: list[DataPoint], activity: Activity, - rider_id: uuid.UUID, - session: AsyncSession, + ftp: float | None = None, ) -> ActivityMetrics | None: - """Calculate power-based metrics for an activity.""" + """Calculate all power/HR-based metrics for an activity.""" if not data_points: return None @@ -23,61 +17,60 @@ def calculate_metrics( cadences = np.array([dp.cadence for dp in data_points if dp.cadence is not None], dtype=float) speeds = np.array([dp.speed for dp in data_points if dp.speed is not None], dtype=float) - avg_power = float(np.mean(powers)) if len(powers) > 0 else None - max_power = int(np.max(powers)) if len(powers) > 0 else None + has_power = len(powers) > 0 + has_hr = len(hrs) > 0 + + avg_power = float(np.mean(powers)) if has_power else None + max_power = int(np.max(powers)) if has_power else None np_value = _normalized_power(powers) if len(powers) >= 30 else avg_power - avg_hr = int(np.mean(hrs)) if len(hrs) > 0 else None - max_hr = int(np.max(hrs)) if len(hrs) > 0 else None + avg_hr = int(np.mean(hrs)) if has_hr else None + max_hr = int(np.max(hrs)) if has_hr else None avg_cadence = int(np.mean(cadences)) if len(cadences) > 0 else None avg_speed = float(np.mean(speeds)) if len(speeds) > 0 else None - # IF, VI, TSS require FTP — will be None if no FTP set - intensity_factor = None + # Variability Index variability_index = None - tss = None - if np_value and avg_power and avg_power > 0: - variability_index = np_value / avg_power + variability_index = round(np_value / avg_power, 2) + + # FTP-dependent metrics + intensity_factor = None + tss = None + if np_value and ftp and ftp > 0: + intensity_factor = round(np_value / ftp, 2) + tss = round( + (activity.duration * np_value * (np_value / ftp)) + / (ftp * 3600) + * 100, + 1, + ) + + # Efficiency Factor: NP / avg HR (aerobic decoupling indicator) + calories = None + if has_power: + # Rough estimate: 1 kJ ≈ 1 kcal, power in watts * seconds / 1000 + calories = int(np.sum(powers) / 1000) return ActivityMetrics( activity_id=activity.id, tss=tss, normalized_power=round(np_value, 1) if np_value else None, intensity_factor=intensity_factor, - variability_index=round(variability_index, 2) if variability_index else None, + variability_index=variability_index, avg_power=round(avg_power, 1) if avg_power else None, max_power=max_power, avg_hr=avg_hr, max_hr=max_hr, avg_cadence=avg_cadence, avg_speed=round(avg_speed, 2) if avg_speed else None, + calories=calories, ) -def calculate_metrics_with_ftp( - metrics: ActivityMetrics, - ftp: float, - duration_seconds: int, -) -> ActivityMetrics: - """Enrich metrics with FTP-dependent values (IF, TSS).""" - if metrics.normalized_power and ftp > 0: - metrics.intensity_factor = round(metrics.normalized_power / ftp, 2) - metrics.tss = round( - (duration_seconds * metrics.normalized_power * metrics.intensity_factor) - / (ftp * 3600) - * 100, - 1, - ) - return metrics - - def _normalized_power(powers: np.ndarray) -> float: - """ - NP = 4th root of mean of 4th powers of 30s rolling average. - """ + """NP = 4th root of mean of (30s rolling average)^4.""" if len(powers) < 30: return float(np.mean(powers)) - rolling = np.convolve(powers, np.ones(30) / 30, mode="valid") return float(np.power(np.mean(np.power(rolling, 4)), 0.25)) diff --git a/backend/app/services/power_curve.py b/backend/app/services/power_curve.py new file mode 100644 index 0000000..799589b --- /dev/null +++ b/backend/app/services/power_curve.py @@ -0,0 +1,30 @@ +import numpy as np + +from backend.app.models.activity import DataPoint + +# Standard durations for the power duration curve +DURATIONS = [1, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600] + + +def calculate_power_curve(data_points: list[DataPoint]) -> dict[int, int]: + """ + Calculate max average power for standard durations. + Returns {duration_seconds: max_avg_power}. + """ + powers = np.array([dp.power for dp in data_points if dp.power is not None], dtype=float) + if len(powers) == 0: + return {} + + result = {} + for dur in DURATIONS: + if dur > len(powers): + break + if dur == 1: + result[dur] = int(np.max(powers)) + else: + # Rolling mean via cumsum for efficiency + cumsum = np.cumsum(np.insert(powers, 0, 0)) + rolling_avg = (cumsum[dur:] - cumsum[:-dur]) / dur + result[dur] = int(np.max(rolling_avg)) + + return result diff --git a/backend/app/services/zones.py b/backend/app/services/zones.py new file mode 100644 index 0000000..1595e85 --- /dev/null +++ b/backend/app/services/zones.py @@ -0,0 +1,75 @@ +import numpy as np + +from backend.app.models.activity import DataPoint + +# Coggan 7-zone power model (% of FTP) +POWER_ZONES = [ + {"zone": 1, "name": "Active Recovery", "min_pct": 0, "max_pct": 55}, + {"zone": 2, "name": "Endurance", "min_pct": 55, "max_pct": 75}, + {"zone": 3, "name": "Tempo", "min_pct": 75, "max_pct": 90}, + {"zone": 4, "name": "Threshold", "min_pct": 90, "max_pct": 105}, + {"zone": 5, "name": "VO2max", "min_pct": 105, "max_pct": 120}, + {"zone": 6, "name": "Anaerobic", "min_pct": 120, "max_pct": 150}, + {"zone": 7, "name": "Neuromuscular", "min_pct": 150, "max_pct": 10000}, +] + +# 5-zone HR model (% of LTHR) +HR_ZONES = [ + {"zone": 1, "name": "Recovery", "min_pct": 0, "max_pct": 81}, + {"zone": 2, "name": "Aerobic", "min_pct": 81, "max_pct": 90}, + {"zone": 3, "name": "Tempo", "min_pct": 90, "max_pct": 95}, + {"zone": 4, "name": "Threshold", "min_pct": 95, "max_pct": 100}, + {"zone": 5, "name": "Anaerobic", "min_pct": 100, "max_pct": 10000}, +] + + +def calculate_power_zones( + data_points: list[DataPoint], + ftp: float, +) -> list[dict]: + """Calculate time-in-zone distribution for power.""" + powers = np.array([dp.power for dp in data_points if dp.power is not None], dtype=float) + if len(powers) == 0 or ftp <= 0: + return [] + + total = len(powers) + result = [] + for z in POWER_ZONES: + low = ftp * z["min_pct"] / 100 + high = ftp * z["max_pct"] / 100 + seconds = int(np.sum((powers >= low) & (powers < high))) + result.append({ + "zone": z["zone"], + "name": z["name"], + "min_watts": round(low), + "max_watts": round(high) if z["max_pct"] < 10000 else None, + "seconds": seconds, + "percentage": round(seconds / total * 100, 1) if total > 0 else 0, + }) + return result + + +def calculate_hr_zones( + data_points: list[DataPoint], + lthr: int, +) -> list[dict]: + """Calculate time-in-zone distribution for heart rate.""" + hrs = np.array([dp.heart_rate for dp in data_points if dp.heart_rate is not None], dtype=float) + if len(hrs) == 0 or lthr <= 0: + return [] + + total = len(hrs) + result = [] + for z in HR_ZONES: + low = lthr * z["min_pct"] / 100 + high = lthr * z["max_pct"] / 100 + seconds = int(np.sum((hrs >= low) & (hrs < high))) + result.append({ + "zone": z["zone"], + "name": z["name"], + "min_bpm": round(low), + "max_bpm": round(high) if z["max_pct"] < 10000 else None, + "seconds": seconds, + "percentage": round(seconds / total * 100, 1) if total > 0 else 0, + }) + return result diff --git a/fit.fit b/fit.fit new file mode 100644 index 0000000000000000000000000000000000000000..0000f56e8d7e670d963d08d4f452e26caec4923c GIT binary patch literal 210450 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

JJ$W&+mUYc$%RwtYzxBPnQBvXR5Z z9jsUb?Cr8522f_snBfq3;mgIg+11`a^zj{dRAopceyKaV{JX6I)?AXXRz-%sj#Q^? z+tHu+7H8jwh)OYT*X`{@nhaAyn9PfvtI;MKil7I;R%W<(Rt08v_#>hf@S5dDEZCSb ztC~vzUNW*e%%tgJCe<~~E^@edOxW1r?F#lgz1@sTn%!pG>{vVO?egLKod;5L`KY7@ zSZ_%p^dy!Q$f-{jN~pzA7Ob}bqa^?;C4u-vt{RJt})V0QPw z?5>3QHbZ#l0a=;V%qNZhXijZ&sJ?P9ePb>Kh%~#|;aK7AuD?0b?5=i7yx?^O!^Nke z4d-#GJG=b5tpV0slBgEW7xNdVGI4F1UBl5te4d5N*@(%p_qx@j_bXv`>oI@(Hl%(J zR)H`(>WZ%_8ZLeSEZrknN7io5t z8$c6M_PkZnYcF1?(|=R_RSEKbQ*J`sPJB1UXy;P zgxT$c+5LrdEZ6G{;)$$FB`LQ8Z@1tXj1%HXAP>Sj8&r^$nFym3w8O}1Gmvf-{pjB9 z97kkNc9|7x0=MmQ_BfXU%$QL*vvaJs<1xFaw}|eV6{)97-Pz^eZ4I#Il0;&pHAJdY zn%&X+u?zr>v!-xaPs4u7`qiQpFeT3Vi=9WJrLn=&uh_r@s3N^jm zV;HiH%fu?-tI$P&w`(%67JXU?vm4VwN)vS}x8AOzO7<}PO8JR*mWq4JtZ9lL-H2KQ zsGBQ=?%T=VM-HIaI)JxZpY2>-Tv@YQZdbBj|AuFou^y)UVaIa$>QWkD-6e^dmo~es zMsDKs{*PU>6K%WVUA0JoDItQ)i=Dg?%r5}O)vGh86C%UqMURTiuIgSFZQCK}HH2T! zDYgy>%B)4Plt8~V&+4^?zEEzbPffG4CYdd7*V~G98@KIt-4bDT(HwlcF6YbvN@o1iN*WCf9PWih9W-gGIIG1lS|fJS-xYYf zLmn8H^Clskp z*^~XfH6GuA=PJN}NNt?=w`)o#D`9rcz;1@Xx4^pa_fqK^TY=eq*hr>GlYhehLRMy7 z44+{BqB*txjqVaB>FWaSPFV3+-tLibtnhXR5B@J^cYUPUaS~V~&f`*dcKLT(1FW|s zQ8%0~<}XfV;@b3fyvppfkEpOO_Ft_=)N^E0I$a5~+l~tPZ%FOZsB*)_Gb%88WKVVvcB15CDto*9gI-75uCm^4wQY6@jb%@^ zd~a69arxiMXn-v&NrYAgSt0mK$6=b?bxl-y;T0EYi_wHf+y15PO{ptP2|+S1cJg*q zSg=9^uyHO=Zdp-W@Xv$_%cVQ2+uGHFh zi#Id;@P!%ig2Q#GZQbhhUqsB~lHBI}t`ZURC;;!06^;`Z0xJmnA)3A|vU<%yUwj{Z zyFZMC4Gd5fyKiGQ@Y zle)YM?6>bVjiZ|2-R7{%TYV3n!uN9L~?*#5RH0~r(Yn@?*GTDnsT z5NK^EGy>)X;hoI)8}w$wv0lyK7Q)-x>B^tZAKux9$gc->qTT+6C9WLvud3dozOEtf>nm6!o4W+4} z;%lV%hSYpoXd2{$SLq`_s^O@bO-VV~3{av*|sf%aLUa(}r$`JGar@zgl`+z2p zjSU~et#^I=apbjVDQG&TVlng2%RG2DO_(LoKKNR-KuOS0%~n&R>#0h8RcW9qL)5&h z6BO)359f$CP2e39vMvqhF5WTGQrU^Og(R6T3>mKM6n1GsNtc<8J#G0#1)&0kDmS{i zh)#@;#_J>+3I!#oxkfECGBj$Xq1L4~g4&8`S(-X~>{o)IO!XEDwle4|U=Rv+D;>r= z*Hf}kCJ1FBzmS4v2$~72V#hda{HS(=*34Smt84e}-8y%IsoTDmuP=1Sszd(z8BFv~ zgbaNL7ig9a`RjQ`bs>@A?;%QhW=oElDD&62K))vLPTjk8>Ee*ezZl&@k%y(6cVXK; z_7g_#pK>IbGu{p$V)a@zs#i^juM!s<6OE|spDu6}CMZFAhA2{*^Dn)CDdS>KIj4HV7vwCeYU-k)cqgmO0g%`Bzh{^B?~5q6fsunh3Ali3H>J zo@(jt*13D{7=?8DD#JY5X=u~hC)SZ*&$qBo-eH2H{r;sisKjl7D;aU zKPTB*mhTW*R@TUh>yk;vdlbyW-1rvlfe0zM^^k4}yI0G@k{qW@YU=LXxmy=$61EEX zD#TvsZ@CSeOe~aS-~Hi8@x7Gm_!nPUNw)s{A0&C|;23wO9zD9Dw1p3jn?IOoxX3@) zI6UeXOB?mnSW$owp-`rl(Tu*3Epx?u;R6mj^dni>P$rv&R2HX<-}v0X{#^Vz_ULL^ zG}DzyvUB4YUOD!oBgy{{IJs%9zj`z7d%o?6Bd%BSM$x%bmyZ8S@hi9Kh$%Qi%i*ZP z85p_<|8k)VTg&BATo%VN%p|wto{2nI3}0PHTEuG6T&c`XaP8xxE=a;$HBzba&~}GpR?pBv(eR^FjIl5B>5BzNY_klFL10 zZR(+srJhJFJMsAjF4kgZ3W)a zMOC=}`yDi9_Bb-E?!!Qv6#mEHp0B&YVHU%cZ&IXI}AW!BoDn0yaWrK4vgOQ zGv7csR`G2!JC-i9i{Z6iHpo3mqzt`v^waB_o%AOBrYpir1<^h1#k~f+QIG}-j5=Ap zaxB)GfLAqPvmiWIZg#-Y`(7s~Ptdi34hRfz5H-5RtS&QQHhh{#))@2gl1zNaln*8j zO;`e5_CYBwjb1LkS8CTl0r4pC@!Zvd1Xd;vyf2UsQaomj-k(SHV$sWzHX$<}Dn~~d zH|7|Uz(@u14NcpJaNGD#!$4i4`JOmZa|OTs5z5OOjWJWmGKX8@%r`tN;=3_l!6%eG zFe__7#Z37Kr0pvVzfUxW;IGC|8I{eRef2<=ToWc7mJItnUpU6Z+F1Q`eu*RACQ&Xf z*UxhB#*FJ01v`Pk>Sm%!<@l!4^ft9_hW^-%%$_|RCYnf9FB$3iv8ZPqvHXPA%iV~j zp%u$jG{d^98R{<%u9sVKA0Z`^%)7U_a}I8^M{+lSnHZHGfcLvko?_2_C;Th$ylgG-mXI? zhEoR%9!`acN&AyYbz?Y{g+K*3+B6iglMjMSH8aI2^S=&qV^v8Wu88z739H26{>KsMM@;Rr z_!}L8?3*idkP9#q&*T#ol7ylEP^P|x2{b0TK`E1*rcJdnb*fu9NF9ZcNiLTma*9Px zaVrjTCSEzjD%a8d+5^1|OrDILI$WPJS4*V@FAA<*{-z48qIPg3y<#`@> zW8^SPe;D|*S~U-b@oAdoz(+R1x*X*7vlhM{_>8tS{b78D7UjUJG{N%0&(ypQ{I#YSb%p1d?7-97*1RK(r@5mYcusRXXa~G6@D!vyiGJ2*TiG$&9pfN- z7?N~bBt?+14)W)gk{nwh8RsBx!tgN1{vt{d$Idd-tk~1sRUG7%Z6(>iLNeY#o^B_} z+mLxu=JVPtOXe(hf`eSsL6RH7GN0FGSmcc1_|2RGz-x2SOfmYb#l<`v0uS96eu(0=55PDA?SG6zSj?&=QmOANp<$rZ!-WTJx{h3*uSQ7HV9 zycM2J7OLr54F`D&{asA*(O)2wA-6Hzs?F$F^OmrA%+P8&@SG$#YE_yx<2k@*_N;kr z7@w)ta^SOiq2O?J2DinyB5u{vYB)+lnpWF^4@X1qUf>DUC4LCf(_7WNDJ(r*tK+~^ zuE3-$YJg2}b>ks@7Zqr8`q#WCjL*?@2fnf&(d)o<_&dh;0-rmm#%*DIu2$E99~?lm z4)xt3;NyVb2YmjJ8n$NUYxNxX+h`EDcBn~;chWUz0(}ge5 znmF*ocA-l)gPJ%8!YKVDO+1Qy2dr(bS>4IIq=C7iAI*g zk(h_V_!4(>2YxFS4UGj}YAH#D^d-D|7iyj*9wu>PINU89fgZuC6AtteY>w{$d$iku z+=hePcC{o8*#7xsO9xqdh9vKU2RM(6Y6X&;a%JXBFOD`P3+n~iSmVD`MOcLL7KhTQGL(Q{|7LRGdhc zL3)K_SZt|$vD(hTb(zWZg*H_FPphX=WcD^X8Lqu9M{uoNj zyYsOw8tM4O%AqJR-n*!VGql#xfq%IW(^XL3Q^+HZ+E`#rb0kEx)0p1<>;!%nBBC)cKvDm{nl8Zy@;s_aBf%OQe zX1Gvs$lqwZcgdK<`@$iYXk8unxYbyj4sAVR;r9VwHa5}rn#;6q4*c!4SRe`A)LoCHfV39jsB|#4Mz@qSmA)-Vnyu zXuTZx!#Suek+~u1(}Ax|O|+YNYqj1Ed`uqEW57L@^x44IIq`K`9|!(bK5iAZaMnuu zG~nw~6K(TZul04{`8$zb;6p9xslYd+CLRcy|18=bl<^#M9a5jSez&EBQ2EzD8 z?Mes!{2siw4mfLu@qY9(<+CT^HIfxY(4!j%V zDGnuV(grzj{~@9h;B0N2m@%{@YSRRVJ#5kjJMb1aqv?#|$Xyi1Co-OrxYv=GArAcc zTZy&;4{2tc#O(~;qz!f8)9ye!7dV>_d#-_urzYA}`X+6d19#m8R{|W*kC=FO#wS*H zN+0gPkKBW593}jSg%6-a#_bH=bk+Z(%a3A4Aac_;;U88I8j0p#9wp_^Wo2tR%O!Sh zj1Id(TQ`)I)ftI)mRvPsu6dD5GGbX?8R|R=T?tqCN*#6KRaS8vP_3@$}}lR z)`3&+Ct3lmJZEX8o774Uvu3@&NxRCSM9+h0{yCJ`mhnl|o%l!xe*7W45E=M~mh?6f z&$gv|8J|C_tnA^0w3^x|N7(7d@t_349<;)Clqt)y!)l`)c*0{CT7g;q0Gv&;D;?F4 zGABO9fuDE+gImDa8bWx+r0QG4(lfNN4ty^9#=9MO2PxggH)-P>xbiGob-+W0&v-fM z;~n_^=g}|)9&!m5Zl^uNo$SDslM?R>?XasN+OKWI+i|&>w$VMoK`we(lC>S=dW&2? zI&wi@6%NRiDXYNzfKHJ80m;KcA{0)>)^=501DcMBT=f^9f?rZ`=3>>Kn?FEMCZ8|8 zDw9!w(i%$eIx89L+#DoC#=jv+Zh@Xh#!Li>B$$api0~2m4ui?@iDucdwlB8I8A=TY zO}ox-oc-;LKI=^xNfMI7l_2YHt(Ba$?nxj^uk{--f1T0!y}6t*ygj5OM?^6xpy{=4 zUM;#9Z_q8p8+4n#E6JZLB0WsLJ4qQdA>BO%mlFei<58tZ-+e{~;*1<+Cd4UzD#$Z_ zzp>I?r2k!PlOxX~r-A%&pWkQ|U8JurmE`@986wwMNnYc=8swdO{l+a-ige{eNwV+1 z%9Ob>YBg0eZ*H}FI>=Gi_>GR$i}c(7CCMo$EF;b%XMl{^<2R1gD$-Lwl_ZzK5V_Kl zxzarokQJ8r749?+w9{|A+`LFX^Nl3=rSRsR$ zlJ7G<9<|IOm$_$y{2||O?C(&dpZ!siFMu3i#(q3%iA64P&jDF$o8LInwMcLNizL5q zh<3+$24D>`)IFNfj$XVHbdh4T`h8vmE=Y4MIcvzES*xMXGBSo)7hy!>0$Dh9U^t@KKEi= zZby?MO2-Lw0^is}7> zbb;S^738y+Nn^4utRY0sp{i!_nG?sPyGFRqb+5qZe|Gzg+Ys^z%y)6fmtmVSBQ8 ziu9m?c`zg-gnt3)i+Wa-gSaexk+v4N?$bH0i0j%%sPLZ-i%r@wCdeq z=}WYAz)#)kH@*h`3noUhfO0Wp{3P&Y6RXP3AxmGTtq1=19e!i^sv_OpNl+^CC?vfI z_=-tYWv`0y71{>io$mG=jpuhuevKl6a!Xs{MIrYoO@5tiY0+e7*@v?*#$O4Xagx4K5# z1e}ifjlS!O^x;?&xtNO?Zj~+k2=KM3Rb`)zrLXlc{`&@{*&R7Cn{hcNWfLkyS@9ji zU#_OP8HvEyhr(_~is{Z~WGn}jEk^4KEymE58pl@-6V>?hfJgksHS3G?(fzPgv#FiL zuaLxb&fHk1Z2`XKali4`h9Z3&rfFlE$+FN9iFyq9`bkw~mzeKpy_O05x+nd{%^5}d zcueWO2%PoB_~U4~m&3DwKk$s-n6(+whYOn6T;l9T_5t6R>X5!s+Y0>T^T@m{Mf$Lj z0z0b7;3NCAY+QbD(r@g|LOzZW^l%GVFgqjUQ{1CSbr{tq zEeH7DulS8>*+qJf@z?{?K*JEX!T1s2n^O~PquQ+bfQvW$M%SDoJt{@e55T$d>H@qF z_?FZJ+rqbKxxjyV({D`n73nWc#4_`i*j*D9B)i#6N@Lu%!7W-I@K@jV8_{`1`nD;8 zo&&xMcsJn55|{l%&Vnu4HsFuE=QkeSR-~t44t-85=n{!x{C41($q9DZ&(!jPA1X!` zY=>rM3c98>CUJmw2i{hg`0lWUXKLGlZ!ASkxwA-bhw1s;HoObF#D9VGtPu$}h4CzH z2k^EZ`i<`ji}b{~g4(x5`_jVirm0a`6B2A!k)`bfUiV|a5xb{I|9L(<3-AZ5#1sPG znw(&l{jFL7@b5nL8=dwR>F;2odqO)q?bk`%E}dJoLg4Rw;Wz5~i}ZV!3i=B8I!Nb= za3ApO_ll%wqde&l;N_XB7!VWmSU@CL}>p1`jK?sMWk?Hb@2 zKlzQS*Q1izfCc0oW%sEUaG%6&Z|!sM1+M?%H#QtB(kE;ZbRTf;dvil!G~;+6l`Y5T z-Uss0U;V~?Hx%h7x8U`JAk)ymWHOeMZIL-{Kgj-npqX;0NN=-Mk~|F82V^gr;D z$lL$&8})83(hucGa*c!BYRTNH?FaeZfBnX(TZ?oJi#_Id!bSoX*#|PWj!Y;BTh>RZjdl)}=P|RC{LCj>B+7+1gFO>uUj{ z=_^J0^9S+ZOb;S{of0=*?*yJRCf?4H9PMV{gQ5aPw>OIP1~+2;QIhO}aRd1c;Jz{O za@5bXPVE-p>%5SD8pZKuEF0=c^f9D!%ezeCatul0w*o&I9WdU1w@Cl|R$zO*cHp-dUi}b1Yp|^~e+2jy7@h<^1UQKW z(c7K)cI_@sd-Z@3_wOQ|jtYA8N{Mq<%O%w4?W5!6pg2q4uH6m1ZH<7D`CXAd>X@J{ z{fNAf&fU3}NsZny%7O3D?g9Q}&4BUI4@LUHJIsxP7U(tGbLeQiEQju2yKMK5Hbi8c@1s=wK zzegnPKc?M}%e{32#=<}0{GY@#BnZhJehzsnJ?kwP6Ca8wJ-t9X0=#GafN}c2MS9#b zf_4v-=EI%sfW$*pI^z!j|GYuK7%z(TbFI^qQQ%XX1dQ~kVm<98JZc7kpteMjPZwn zH*FR$zKJQ;-+2YA1qVw%&Uh-B_%6%W0rxO|u2hV;_C@U&F28LaFjC`-^?|RWLPf~M zh=_+R#vo+DxGEt>gxhdG4E#|eVC<__tUqu{;(XU~l;ebV2Ogs3B<*k?2f4jfz$mLx ztk*g%N%kaixaEX*yG3qyKLT=Un}D&oPO-k>9Z9lZmoh!k`4*Y)eiUTWb^+tZdd2!T z?@5xYs}Q-(BDc981NnXXfHAF6u|Bp~l3ZPd$Xtuebw3XB>5c)TxM{KeP>CHos}9v> zbdE*lxSs%--8o=%H;VO!A4rl%ctYe>i`=T606DE|z}VfoSU>QQpyg2NvldwoGP9;s z$q#2omi8p@E4stawJ+8ie1Fz95V9f3YQ~FZSBH%o<&iD}ETN^s?t=cod&-D%%SM)5_ ztNt5R4R9Xj<>J|k@m>-5v%o*-8!)_mi}h!|#S@UgdCZv$e|N@*Mc~f?KXfJPmwrgh z_kz9#o(`N{OB^M8x28tm&jU~HA28k?RILB=qo8xZdF=H9_zS@Q8~~R-tXSW57QNb` zL^nZ^?B`f|R%#VFMb0Lg<$e+P9fJZ!!;vu2UnS0Zmm^Tg-Yr&!Zt*gC8s*@LgtThz zllYK2Bw#!>rdU7ty9~y!H!__st@LiTf^Bxc1hUPrfN@Q7vEJyf^T|yXxk-B&O&>O0)l`ps0;yQOzrs6UaOzD0Wj`10`qZE`Xol&emnyAqFQSw%~a10R*yg4axa@7n=&+#z+>(@#k@3{6h zF6T@M7*UzUdjDDqJ&93zp8DfbI0zwq6XHVsob+^`_6~4uTEO@-yI6m#jzXs}CZ7YG zeN9#1c`0#+!x@~Xy$gKb)dAz5ykdPyJ%zT6#q1Aoc6Udqfp=RaVqyQ<6A&H#UJCS3WhVm+&|Lc4(Tlm-vv7ox7;F(EG0 zH-_ieih$pe7BGI_3wPE`p%&vYj|b!A>6in+cc#RJhD6fScX}BA>s4j_+&i>lT&B(r z7@zGg)?aM_vqs3hR><8Pa*{Ko76h)&4H##xFVSWP61k1RlDLB-+7vsBOzt@cY2~ zEeaUV-dU{A?4;0_z~8l`cV~Q3obB@qJ&gbJZ)MZaI*6Xf6k zr>hT#oE#_Tqu8hlwGV+8FGc1(P^?oAg?J~GGw7r;-jngEakl#})II|K(DHy$c&u3e zwWmVY0_TAZ&J9y~B>pephgPC4daPLgt&c+Q0m?YG2~={wy@ji;MLe$0^hg8sUVni|7aOISXR#?8(u-0)AU|z$kgYSocj(Xs-hw z&-m;J{A=LreDJj&7wd~BD)gNLpTc-MCmzSRb13lN_&h!@U`+qKSRXz`p>D8y9&O>o z4X1~^zMhWYKJ6Reo$>?5YhM@Z&8}8xHSn##SpiovK0X5f7PxB%nrGh?>tD}M=mp^S zU{r(gF^rFkz`q0jfJM0Ct8JXPYpu#fX;{{eosFkrm$OR>IujzWup zp99W0oXq&xSh?1M?ails5B#&;0i(ko#rl}}3LOT{x5{{LI?8Fc%cIZ3_%DBmQTZpe zA8`5WH38$!{}${07AaJOkP9tS!VIq0mlA8&mOkxA;OF)Qj07d94_pc>nS{64z?%84 zTQi;->%5JhfLGlgFt&Jt`luBOH3vS|!kaTbF;-5a%iH)Fc+&#`!xIzK=d8xo8NhiO zfGa2yPmR#&S>QviN7pSrsHd${XfE*2kan&u>rs%U+t%mPegU3zL%?{edQhLVQK9X? zc}{}yri@2QKL(9LsV420ZT;SX;xOzA;%C0qL)37@O%3)PE_!`{Jfb>5QLZyi=@Q_vUDS124KeVCY?f z`nKH)t(zt*Q!avUb7Dq1@NDhBz-QeXF#7ZeVxUN&wpU}g0d+51*Kx}7W?vO+SJm0t zKfwQUAMoBm{Z79^&jJs1CHxY%+YQ-T8SrgK0>+3dgL>O*6>2pdbqJ($Az1)CXLzjL ziOtcdTAe|O4+f0U1B3eG*C{j(I4^%=cid9q4$XKOCusJ;gtP=r;6vfjfU#+4Q15kv zLMsq9(F)s$Q#L%-?xJRU82{~0@w@korr`3YhXTg&tAhGlH!5@uLUQwiV{JqWyjzDm z%xJ6I1-$da0i)K~puP&vJ2Acl#v$vB1>Q`{j56IS$XUnHnVk^SJKQeG8J0)E{iFRa z4Q`9u4RY@z$m7XDz3fg&@^C-r0+X98a+BKw^4OzjAx;nKU*0Xrko#vc!y+@>8p!t_ z3m84pgZl6HN%DyXvSg#sMs2jnjUFa{`BR+po^eOvQhg#|44NO*t2`(Ju{)4m77i3+ zmXVEaFUT1u0!F)~LA}W_TRe{_N%0{v8svc|;aFD%^{S6Zaw437oHYL|YJ(+ngF6P~ zJx>LUx*LM}>yJrtAQUDi%|DA;Z;|WWu^`i)4j9^&pg#G8B)O3ZMOflI^0OnNBBJ1>^%r=Z|?oeaN$tsuDc^ww9P*CVy5m7U11%MAM}dD{ zl02vpBG*{t8g~N7Rmg{o$9(lP4WyHIRkJ zFv__b_x_qB*#M=?lXg0_>L7nYdFp>nP_K4Mp<yyWrhtSEWbt7YJZ@o@S1U?X@ zb??5Q{{34DRh=Q{dE^*8@Tw7b4d8>}I<);T#dj1M0-T*Y5AJuPLs9D+#@Ox3^;%8f z?cm40I1tpoIit{8;O${rJju|J@pdt`mt3#a0^a71fN|hpP*+P7x)=Cgz)myLC!p$h;pU*8{4a&#XX3F0;5&)1UVg&aH?$mA+3_EqlsAZP6j7%T1#>OH=bp5*&gi? z?MP71{y~zwY=P&MS>|$!T<&fN@<+6bwm%ruzxr8{KVTNRok_OxF0;sG?nWSwqJ5Tl zEU5SURg#$?+nh%>2Dv#mU_5<1s2})4lIb8@pGP(UIRp*T<&WXs|CVG-6QWiYxzvh% zskSZpIp zE}mKdxnMm?#Ir&DQ;#H1fNXA&i!E}ohe7%)O*)-(J&}1&mw!$%&7UPF>-M+w{qrdZGewQ z_qOKgpx(KTi*5sctA$SmzF<;}-GW`9wFTZk9V5B#1og)CT=XMwcH^9mQNR~oG(FA6=P^D$#vZF$s&xi_ zW?aDd_+ymUb}l*&oO=Z>4jRuE)<~Bgm+_gjoTY~*0n^i$ zYF&Y^85uBgzXkhp6(17vMH$i=Gl8fF0&eQyy7{-^6kFiI>mTNtL&%x+sxBmq7MZI0LW{$1*TY#?^ zA9Hh9?<=$<;E!OaweW|ae&rP|`X2ajNar%Tf$`)Rdyr>^))V;GR|JfAe+ud~`@3k! zTsc+F_#~PWwQ_unJ;<}t!}!@h#r+vyXuWVbpbr|6zo4EO=%Q;8k|W~V7$-y8x3N;| z4Sa7;%0Xb zuh#kkZ`?It-1}EhA2iZMLEya7f^TDi#O*&p_ zzVC)Fv;nw$1wEk$q5XR&yJ*}3Ea9_m_(@(3H8R?EDyy}Dz+b}j(BD4>^^9pQn!Ql2 zb1@K6b@67@nvsq>SfdRBet*+|(HAy%^$ZvFTZCsgk++P00O@NxM%%-wYqi0^H#H6z zL;fAqN2IyvzrY8>WikE~@O4*2hh{y~)7NQ3fcI>ONih_!0dri`XR&P1Fn%TQ^&_Kg zn_I691zx2-`f;D2tm7HcCBS*)i}6mtH#pP2!NWLO*-vjerwzm92_0>l58+A|x#&iO zi_U3d zaCvQ1z&P|;P>;`aQ9TUAhC;UEkcrNa+F0Nx-B2B>Sv4DbWCG_r;)tp<-XsDa2mA>o zV0?i3TjaWEEbvm`T*ozLyi)`|-huzL4bA}gEDLW)Tg&6gz~K=y`TP*O_wNGE=E%|` za6)n2;GW>XpDaWn1kQCyQ{Wn;a~3hV&Yj{Qm+X-w4}>s@5jCvNmgH)8s)MY*Pm(c{UN?Im;Evxr9*D%eaZNv7oWha)89tvNj#~U4-e)7tuSt%SDqM_(-#Uh_;7cR%Huuko;prXnn_*5|Mj5O(djXuA!xR!axI|5d;7Ihy3b zM_lw1aDGo3-_w4H+m+Z_5950$c+{~gwb{7bgEf_92zl4zE;_SPRvsMkbA((sF4`V0 zTBpqczVIc#5szkk!ILh^S|wcvNePxy_0y^2ASmtAxOA-Ms|Av+>u#<=MH z;dEta3xO}ea@*U{eYo>A7nK6vVMWvy_@?pEcBf#Iwg~u#Sj_t&`Vcv%TqM>=r^C_> zdRyB(KHBc3Z`KwAAMr4j)uRtFEd!o@J9FgjV`E=V?S`w0pOb!U5&3gBPdg(b{r z<^TDSi=F|_j+}7=_+}@*Sz8JGr91sb@bjSl^(V*z;G-?PF5@W<>6^7xz@NPx4-25# ze&%y@v@GdOAid`K(pLk228-c;{|r>!Le=vmzOaX@SxUx)l2jyW$3;F=O;EA z0v{@I=W>P3!1-ydS8A8&=dcC$1Hie-#`qPCr#Oo97Htdgb9l<*T>TRLQ&pw!fV0LJ z?@PlNw|h5Rv`pa5fwym3qQ9f5l(=5z2jeY)XQo8k9n4HE3%G&DyfRvr=#NFK)D<{) z+8J*IJZplZ-Jhjx1wI4MdVSiiM873YrE$R7I2foaaoFIZ0Zgw(XXhZQnd}z9ynyb^FnHQ!0!Wo%kUDtWj&SFJMg)T zPl&cNc&oMzxF2}uF(rCJLzT_}XC<-p%@Vi$^;Rt(_$uIMCY0#EH-_|$Hl8DKdkM%^ zZ9DLpz}HVH(LZaZ()+;KmRWi_t%}N?5N*$}WNSNse*`>cW{G~rP^oE#bab3!!z6BZ zpt7}{z*C^p+&Lxs^Q~1{4LoFRnCEJD13!r}ma(}+&+M+!iw=Ca#O-F2Pul~$9v+SI<&@~l zd#V((*)B9g&zF7;@TKq%KW{J5)BC724LB!;?|PiX?GXf@wioytc#>+yH6{AQD^+?F zICn`n$EHf$zH6Vh&w-B~pwi#Kd6b>;!Sn>*s@**DX@20S{WJNzax5%_J-b>J!8|0XKzXShugi2Y!xl6)$4aTjeevWpngZyxm zN*Q{4*YPcO2>h7Vi+IHc%SGy9r!^9e$8Z+z6Z``To-sx#;>x*0c2us zaNwEKRBE_YCWi4&z+L3%CL;L zMLU~~q6&Wq_d9NM2pW>6(mfEw^MWjBm=qM+ZGrRsCg5XGW2OX3^mcPp`p|)Q0iH9# zYa3yXhjD>;(UG{>5wiAtyjvz4^A=V_on=UAF|v%`;z*ZRq|#X64T0A|y827pzQG*r zRtNsg5|uUr=bU1^H64!1PVw5^lx*!b2YzO`N;g=EVY~_B6TEVDF{>}z!}$3AYG=_& zyB(LQ@E{vrD$!4@Qt4HMWPQ~}$aXTM-OkOf!g#2I6MqLj9L9O34;In!hqv(%=yydW z`t55~^5)1JWI_EP2%5B8JUQB(z!UQQ#&5s_8&v8IoELvE-kI@~2%MuChNpMyo+;6@ zHmS7Bl3ojVG6fm8>zW+xF5tIp#sl5Jr(~-1C2(%+Gd_v&WUs>k$KMV79-Jk7Bm80d z2DE{TOY}C`Dz)>WL2HE_D#O|>iyRN*z7-0#?$KDp-K+dYaj-=H%Li}l2sxTVj`!N` zHAlM_`1MQC0{x&we|MWoF9Ba_MRb+KZGV}g-3NT&Jik%@lM?;t4m4qM@!+3@kEU$C zC))w$X!iqm%|^z4UZUp~sx%omx2ISIgU`p00N*vuZ)AT}qNnXq=_v3+7CuShj>LEv zKl_9@SmRyo0bKqt(Qn-PO^KegPoHIjQ=;qpQ9yz7 zvN}#zw#4nh#T@M@@Vmz1skk3Ybh=KZ*4w0gGM+^%*sSdSVUG3?@B^d##_Y2t`u7Ln zZh>aXNW zAGrk??7;g0&rSB)t%h7L<4I%7%I-=?i`9tM(_WY=m&0B=|O}I-FG*H%}aK8 znmp|h;5YU28*`{sUw>GoZ-9q#yBEeO@+Nrg@#8%0QQ*V+`i*B@rTUnARO+~0+7cJ1 zA&e(`?M7dohw-zg#2d9f*B--Vzg~XBrIqTf??=-HA-Sk<$Uz9XE!j~Jw`q?9-_!%0 zGHdQ{3bR$A?Zm`2#eqKoJY2dM~(X^>ms*ikHrJsTGT{Ax6 zeEb>U0}a2ipl+$&_&JqE70BCQd@Atm$zHpuyIp%0_@rijs|-)oG)gT|%$t1qe4zfkIwrRPiBE~?wL=Ydae=r?|7R;nL(Riz(*SGCf9 zMB=tiw`(r|KVHvotZG@RZ+ruq*(JNtEd2rCJH|U~e24ZT@UwON#`kSX_33XRgRy>? z$DtX2P~!IN*ADF@@XfXSMvD%mdh$Cex%SBR4dch@FP1JFEL>~u&|U%_UBhn-?p&%5 zJ%d8y!0%_=Sw44o7(Z7eUaIz<_A)Lrs`-s)x|Qm^gD4Q!$cVTdkR?Of-G?38E5Lgv zU@$4ERPXdY+@AyA3VdgZ*T#2huL6HM&Tn+xedNTnYu1|Cvcu&W_%v?@D@yP=(Iq49r*82ext{LQvI*bRQdxrSN@DA123H5 zwa4=cwKssLd;G?`gG%*pzEo-IJ~<)HcpBqQ&r+zJ0{*beZSN{;~#CFW`= z-EQ&}YHtEph2KaUR;m|&hgW0yZF~gqT`69<1r+DlF6}Mg!~WT43?5#pfAE7!(}DBq z7Ea7G+QztSiAel3@Ynv@XDGlw`x$x%KElG2ffuGa{8pj%Ht<(}-)HPaV*YbZrPzR6 z)XCDPO57eNDb(Hpe*d|BMo-|ef1qFiZ;dr8jNi|RndA*U&dF(i7x;>w_Zj)Xd;G1^ zvi*2=%)%dGe6qv)?D8=F(|gL#%EQ`wxSaRHKH~#~9ABnVqieBCDMGTf9i`7WBD)^h zrJVu()wlbMqrjIbZaM{=)5ZAxz;{pb-fp(TSxLLKBH&xU-e=T`Qr`c&+{$}+i_vrKd~q6;mu&|L~L^Zk8B$FV4QRV5i}$un7Ckp=GeLAEa0 zXGBjZ)oUh7lE+LrcOpm+lb=7Y2E=XJ2e_PCywBJ;sZ{^DrklqiCP;#Ho#oeLzQ1 zwf_Tug6`fe$u6LXF1*sjrHfQSIw(>EQt6V!f*^>32uiQ%31kyO=m;t*ilB&H5fxFa zSQ9%o#ID$t|M#1_yYpav=l`C=Ip(~d=gys(JGaklD6_dsc7Su$f~n_{4_B36q4@;- z%_EWUjvK2&ty;R|5csQ&EN=#0H1L#a;8XBl--v{7y16R!YS<;8g7deynff_QeW2GF zkQBO(g0}%Lo>vu`*2X2jfHw!{Sg^kMdcL7dtgIzs}%i&&U`Z(Ww0shC!k??OhRiRy-T(W7Sy&mMhGU0k& z=V{J2UxNSqQY5^hpenSgt4pSCvR9k@u-xp;Z|Zeg`+V~i_^XE^;Rz*Gp;;HX4)z zc@jJ}t{+LR8?pj7`7duJyj5qfIgbAU7`Z>4S1{l zC2otCGUZA<(7QBqSmd1~kK8G31#a@+9;|bFLb>^w`Pvl;&)JP;WQrUcr+YR36@18@k?g7X%Tcd5<) z1fRVD{9sk+`ROj14gO@5w zBjI=6s|qb$hG^c^TwLB7@dK5kCT$j9}Qtw zi>gr10+;*?&N*FF7W#nMZ)FBszS|+UDE7!EY(GM3mO04+vEm6cR?ilz&BMP z&uW)+1m`jfQxB5sycZY)KI7I%_|@;LLdPR6Ne5>qguEqqzUKMH1OC>WNchX2szOKB zxFj!{nX|yVfMXQqT$w!M1@AUH68`)|Rp{ADm%IkfyGlNoJk9H@hUFO___mpm@Sne< ztFXZ(Nq5-1llQjV8FA+sKe*=xv~_=>54Ra*2Atg@@>Y_-%;?k;!HcIy!u3v8h34Gm zlCj_%U^Dd=_majM`P&XRYusq0e>fWLWdBs|7d9qO~g zB`<-qts@^mo}wz+JQD=(eoZ8t zD14twLOX1wKzYH&>d=XYT(agaC-rr;`I+EL#zw+Fon0Mz=Mk5< z@3t3`sowyO2?(cWo@*L_%jih>oW|9m2OoDyd+%_Brmw+X+&}>1s|-r@&F^9 zWmqo%~b*K()ec2^Z&oo~(sU(gR#{YBNG#J62?)qT#i=)E@eE%E9y-!uU~+B*^+cyV>8>Rq%9yD;^S zQ)TVCM;_soao+RFH%-B3UK|N0^r;TrUhR@QzXlL+-abwa8b47miY?7nM%emm-t$-8Z4$nTcVJVpA-6q)nDM_w2Szci#ewC@YFnc&=_OTM%=ZwY>=LnQph z@aoXgV=l?sV^e3hWiEK}NOj$d&H3Pi+M#wGSsfbwjZ02{03Xgq>g3zVN2%_0u?d5J z(mE3EkXju&=X;mz0_SyS-|imrd>w~F;02Nt_+bsV7W5~U2NKcFZf?1-07<7(7JzI@_CfAPd$%3Rp;GH zo{QN-yzJ*Q?eWL)5N0eUSBF-JTL$j4hhpp%@{I>w3+KFxOb76ZXGg*}POT12In6B( zf^)i?{6=u}6`Wmmh2{eA!UmWgm|h(k;&w};hwbz#`DDwT%COLM1V3CKjnIthP;0MS zCM!OiJjHug{3aBd3&ES!jf9unP#tn7x@9Lg>rSqWUShfPBo>-Z;JuR~;e$6;hdvIt z<@`r%Wk`N2cu}${Z$+jv_*`Ek{Nc>%&_ngy@*FtpDDutnFiV6}M-`hc;QLJ^{MfAO z(CRaBs*l>#xm=t9UXrZp{Swm^{H@a?;nQxa4oz<8mhs?BoxGpr&Mhu+lRq2rJT0?L zH~i=Egh=>r9H?E$ExQrPXILG`nT;(m-NAqOw=_Hz{GY~d`2?JGI(bJNXw@h+A+gF_ z1b+0d((q=S_3KUC()Tgjz>r@doxI>q1GCEX0N?w2X?Pf}>4xTRIRMUjpDXsuEq4Zs zt4vSudrp*wPY1ugrCSC(ZZku^$Z{v|tIWmVpZ-)D{vJ1YNGrGO2WRsYg_#luYn~J9lig=?#ANx255&*H?#*w|C2MaNYu@elhtduk%L1 zYSRb&+he8SLX_p*9o;euoY$Q@CBl|F!|2tfFL=W*OT#7LqdU806*wnT$p2@#v+;km z=?8w!=cVBaWada$x7-W<#@TlNR+Kv}^=emt@IyyS!%4U@EiZD*li>WU7`J=X!`B6z z8M*Yn9MCm#+mQpk-I_8DX0!D8Sa+XAP1ad zd~+;4-)Oh21MeE;J;+C>XRpxkFb%#g4F@+@hxUwh%Xi@XehgDj1IMhrGax83 zqrhFqOT**0Rfi@`aLdT&?CUw?S@Nj2I73YX6}!oATbjiCl8paM!M`OuC;ssXk{Pz6 zI&{V)w`@l;b?^u?nRFymGFsJzB_77%CxcGbsri*7!#~@gzBq7ib?C(`x4e(&*b6xq z*=T1jsw9{So{oRZaAXhvaDIKr@XyBIm48cn> zH=E~{pH=GJ=YGKui>$Ubjv3y?qA}Tbnw`#rfrcrmg;QX zNnYe8_X_`Ku27LDm*K2cWD`cFc$XsICu_ED6`4tjf4vfS5}ezOn6Dss;mGKO01s4X zG8KO_&n>&ax!s7ZK|6eJD?YPAUSP5mf4t1?=*1!ObsUJ1=Q1)jxmA;|;8oyP<6jxe3IB-Q$=k}^(pw$1p)_Hv zofEOKjGK&q<&5PYvDl4kQ3WBEn~~Y#)<>0&owe!^xR? zuFhAkxnA=J+|pn1`j}GAjm`A(G;+-h#jk$IEt%kRqp3F`&p0JBHz+>y5x3l~_$A&ud1<}+y*Wzw+ zw=EhstH^ip%a~P&{3;sR8j*SWKzU}4;>TZcOP#~cX|y6A<8ktpXXYv%c*8C2!1;p& zJnJ?!T%AUqxkd3-Z@FbG`03yrzP6Xsyt$)Q>bd4t#rwaD3K{$-d!D*n)Cm-UK!>+ z-EyZf$u)}RO@X^M6>$%9ayer%1C>$(Q~{%Y!rGr^hJUG0p#}3$WDW zD1PK5E-yHreex{X|oqVKFYIQ@pX!?>uTg)aNZ(jW+C{BQ64)f%FL`V>lL4Wk&({t z*d-rUEi0wSvohIZ7nI0XnhlCizSzjq;4EIO-HO0-QapCvi9E+_RD66NBbU8vJ4@ub z;JK+DyR<@{Yc?sK+~3HD;OxYauK~|XQ>o{f&592gWMuk#wj)kn0-m4hu`_Z^J>P6m zyxm|UZ-R4FKt5SEW80#tIttw6(T07iiadR&k#<#fe=%E(F^DWo^EmHz6uNFx{I20f z=7O`Jkq;-27PLawHYG=ow3455rL3!^*{)>$6eE>LlOt!!dX%G7C0ytwe|OT!lbh>i zd(G`C_LVf0plZ7@i9LrkHrA=D3e6pg=ZrS;4mc0RKIjfP;wd~sJ++1APQ{0eGcw|R zRNPTMhrGGRZgS>bC^S11HxrHA1Ej*6qtJzuXCM|2f+D?1BYG9$&*#;1p)F-sM2`$HGedV{$cJ@2_2hmr1eL( zmS#dX+k~8gUSM`9e&hxtcY?E^lP{FdJq0NqyN1YXR$%T|{OMUn8h-2y*=`_DRX4D} z>{k5ln~lr>XX?BNV{GcqGhJZzC|-Gsk)0~_-r$9)rV@WU#cwM&@~+}7$UA%NGOguLD1OaaBWE794=iVP zdXe|>*hM+ZpH#f-dLz5Q`M{E|mu0@fksdpVZ241)Cu}lu?q~Lnkk7E(d2kBN(~4JZ zF%kji14}*|9M8AY7#5iQitpQo*6nj!_&5W&mt41<1?CyW*WQ6{0ywv+aDH(E`AAh( z3(Nt5wH2Ovy04&il;tlq&;{)q|O`I z*7DfUj{GIXGxi%v1Lr`9JZyPvz)pTx@vELiWdqKEBYBJ3{AI;wA2hNWoWD;<9s)0P zM&Q}mh2|B-b6>=h0M1De@^j@ZU(x9J7#W!fkjIM$|A-flS5-pmUPg1Tj@znMLgqEa z?|;q6pWqxl+p{|BzuA0U@yCxCse8;0hxtgIh13gmHVe%giofzU9$0W*1oD71`fut- z6#wKsBOSqc5yGw&!q`U@jRz=t)pZ9z8qSamn^%)5&JaLma2$MFrY z^DOt)dCi+UF+PUO&dxRODZcYtBaeTFoxUwC{}?>)n)pMPot+mTkCzqx5icuMDxu;Z z@dW&c(f9c_p|wnCT70B~giN*KQ%)Gk`5CL8;A{{(gXd58+>h(Ykxq7YzIk8q9)B1a zd;$%5ls}ztrmtYSXIG48;_U{-PyB7<@UQl)nEDFK?T!nio>}1fQ1Ls_1K;r*4vT9> z{=S@vS2H}!M`oexBPBfR_(STn>mGU=rSFP%^u|mGhd%vZCc@O1{;gwk)t0Vp2hXGFA{= zpR2I0=h(0pQ8IW{u<+~4i-zU7zEE;~V=MV-Su4NBk22L<=}CF6FO@vm#F6Z-;|t{Y zgu-xuW}^5*eIjQnfGgKa!g z|93P1QQnTcSG+4meq8a4_8u7k{%lj5nU3T`oTnB18^tR+dSoW}vN-?On>)ebnYrd$ z#b592k(a<(7w}ZaN)2Bge++WXcZz$ud!)@DXda_{lI5}awrr&Sz2e<^dSn{-8%=EL z1IWk4$1val@>mftKd9KO-X6IfvHV^#bJjyXV{B|Sk%#(G@#Xz+6TmrRP2L|oXPhUt zJeZxCV}4Tnwt*h$@uz(+j69Wmtj9U;9P_i{doT6KZgAH9EIZhO$9Y+unO_utYlugh z{AK5q$*;FOHil>3PbmK5aF1*NZ;HIL3@;!b8=qSs|5fq&qdf9mGopsT8^0D!bCi(A*Pss4d0mW|x$4Wu0*PET0XZ}!p-dK-Z_K!W) znu^h;{-7#Ae=2^*1dqH8&bvxp%+$wvo{aOn0C~Jb{H0W)-e9$zHya(>XgG2r%ctJ0xvxw#fzatRu08E|Xk-P-_5+yDZ`Ta7GJ(<`{ z1R4>Opm^a-kDOlzzbY8z8^Mdl#>YX~nMJ0K;(KR%1718XKINF5S!_;I z{Drw5$v!Qirtp*JTOOO`BR^g7C+B%&KR74W*zuVJUNX+94l_%POYw?@9%*qpeoPo6 z26n5mCD&In(G%NbkeyW$Adi&@ms`cwU4rvQEPs`bv8N%n*jDLT#V(^{mt|I7j}1%g zNn(0XNsp5ImRs30Xh#8*MbR{iTwWzFURhg4+qSF%mruzyIaXfbwZj~Y0%FZsR-VhR zWGJ_`wA0erSvg6GkO|0p&C_zaxcE2zdlCP|B6)81+vWXyc5XU5Yh|D=Wb6_K8tFgI z*MD57|LCRv7_9$DQGa;H;1BjZa_VM#0u$B$6S!9Wk#rsYi02*i>iqk^dGBuQH9vXs z8Z)_ODI`04vTK@>r{&q}^FLfkj?wUfvY8Ty_ME;E{nS zMN0!#{!bn-)0%kgkGinuIL$Rfaet9Vionl{@}cC>gr~V~Q1ZTF)IhETnON5*JV#4& zqmnnS^2lb zPnk#BngsbUx?9hm0N%jsbd9eMkjKi0xk<%-yT&8EJqa=by99Z`>mznXtLUae9%_b} zt@yM`k0g5&5Tg>dC-gqBBd$zen@u#ME8bxzE@aSFi{$r$-x7Pjf&3P;Kymjjk9@9p84A$6*xM82 z^UOlU|JjWyy{@gJnfC?Y^OK_=Zy}!_Adi=bMJm>{*CQS3CB&ym4kLC!?8_mHU0@a~ z-uPjUw5pHa$V9u!L;XoUI{HqE z+wze=FJG9&V^slKY?djWv)?1V&P=H3kdiM3UouYJ;w5Ie;`cx6kyYSqDEZ3~Y2Zu8 zM)!6yGfT}1#Sb1tXBwQRN`5i;veD7k$jO(Pm5P7+BDxR_Y$H$J6W>W)F*f=Z5%~&} zqd30xEnUFb2PMA%eC62acWTI2nq0-tdEFz|fb&9^rz;(OCWz$0dl(8x#Me7)R;_ichZc$hl`F)XY`! zH$GZPQ%}C;`KD0uIUk^U24}~t9e7Ldg3;={3rvyXxgUFEX_WJ~M*3PF@7ZS;m}14Z z9reij;Ji%a<1Dv3OnKe~rbO{azVOKChV~Yd&m$kBN^^l(rTFW|Jo2F86Qz;2aI`lz z9h!}Kw$+M%_Kin=1m`Vg>b)(GEvAr{Dt`QX^z6@0sF_mi06vC1BlJV^P$yhPliV%zleN|QDTHM^@+YpyEmCs1CvT;F9NnZ%kJY?nbj$@xU0b z`~c3|)F1q6@J;E_?=vy)n*!wV67i6VJvq)RsTU;F?2Tvam5fbO-K|Y#pW@$6^vZ5< zHZtT_fN$0}VY7Ky@%J*l;_aAFvz=%F_!xQByE)YxTU*Y~-fSLGeE*fGQo#Ay8S)Iv zo#MIKJgWG%YrJwlI2T;WlP!0y>Sptp;yF{iauS^Hhmwy3-xB*Y4KLFc^SI)(r=bbE z(4Oi*@S81uf9POTcHz2;Zhu)gjts4k*57G42^S-)|uwM4l4;;7?6i zHP0%Zv&<`p!TBB}d1LU6Ix`#1bBYgI>6Nd*xtBrl*p`dz?2WGH6%XWjyz>EmLGi=IUYXF<7B%MMI5?`d*v69V?Dgi5 z;(J%4YU^eT!=>P_lQ)mP?a7O>-n^)IWtmr&fwLf!XOLeQeV@(pmlU5;;gwC`+*3i` zhZ$?CkBva{EjR~0{Sn^$}n*~|<^>Nk^*Qblc@c}?;1+fj>x$5LNn zQ+H~UbzbsS*PT4M9P?=Abrm~xhgbeVEXy4aHPgmARm(c_hT?th@yfM55^6SOl3yot zeU-_o!mcz&6mNc?S3U#h4ZI9|er^7y;(y=omHs{LRVANDK1#J7mF6wQ-*~_)3&ELp z^4{QUV{az2c&s&VD}L`oUU?>(`VjCz*p~qA)I@6ok8*uK~ zBJWB*Qq=$z=0nBXz3i3Ny%TC);O6f+x3N67g_Bq2BgKDs)hor|Or88{%j0Wf;2$gg z^c!CJ9-L1Cc~dEw5bJ$KF-`PVr4xO~92=*F8WUFA<-r*op6YGBNg6 z#IDh?Ys^u_+f<`y_q8{H{3^?xe$*QCnc_cv;FVHvzA#FjY`N2)SYtj{{OOPJ6oa$o zBfn01_{vAA5ly-ILh%(xy>fj&dlSfStj)hveApK#5sFW++?i-7H(x1!&M~i~^|zVf zuVm+wk5u!2<>r{;KOFbUF>pS`~iz9;?I4D25NximxJfZ@4oWbH$wQ( zmz(2?@A%OxAAz&$M1H;H+vCIea`TPiOMdan#RKi5H3EDBd5Y>xmj}q>CE{BZJN7s9 zqY%rdn6W+Wp`2>C+dqvEgC@yUbW>PoXY!&`ETmKDt>7_ zpY*uY$$Jgg*S*60rg)b#eNqn2fdKP9fvKmejzL9$JYFJxSFtS``s4={J07uXQ{(Tj zuz~tR@%ka3^dIaTDp~yCs@Y#_{#5+0#y;5s&RcA+svkLXmVHq-{-yXAO?=YmGTUz_ zpU2dbosB2R%-@Q?(A+1FgY&^5-%6eme_Mlj|3~rrTl!?qkc65~#*sf(!_}a5t@&5+ z4Xu3Aa;VMBDDe9%kG+_})K4m2(AFor!Fdys!3!mcSH_tx#OB)u&XV8t9emPyn7zg1 zb1ipj&$T8&@$?IQvILyPlY9(#Womq5aCUa3siSzmErzw!T4!8}f7;6@72s_C z$S)vI_BeIKI^$OSk-k2u0%wJl3O>+sXEuACF^X3V@JaW}?Er{;qUFw*W1aCRe)Aym$mu?CuPoe27?{RXSqFVuX$3>c=9ecx0?kyrXP3$=h1)bl%sR zx{A-A;FEUXtT4!1fLEl%Uub0CtDfQ$Ci!F-I8TJUiRI2N;R;h<@xIwU$pvR$pS(8~ zRFJw;nkvj0inqAhC!Z-k0UW35jG)$yr-2_DRS<>O;ZtjK*FK;DggZ@e@;h zk_FCZl)N)}O8j*|^0O3wZ@N$N!I^jR);2RvuXL?xsQA7ce6kCiw~4%^eCk8q;#I3w^Q`oUi1O53t<1XBFmuia)Xh z%^x`H7xIxZpLut#YK1vh@sj1JUsCO*(lGl_?0O$%@zNk#3!CKC-0Y% zCwpRVZf9qgnHGxgE%iwncr5kdmOE8xS%5rVBFrR6@k3$ZMGJdt$7M)Xr0F)bCJ zvDPQ8(jC6Ya%YAtV$N55z^mZI9iG80z(+)X|?2+pF-%#0*YRuxv6 zX{Gp|TYSvk0d+$;Ibp8jp94*@W~V4%nW%? z%bg}9V%jRc}&i75{3VPY$a?b+OzT8kCv~6+iT-PnwT)a+V=Wn0kC$E>iEL_+3w+jsRz? z!OSeO+^HiS?zvQq_UI*vu1^Gm9 z%QxgvnDz@n<`1Z@-+1pM!D&wxTo4Dh2XKA-At}F zEMo}XTk$_X@X5R2v8y`Vrtb8;%1s}|kAIAD$`uYDNUlpnx#_ET^--VPsCWimtOAd3 zhel@lDSqG!pX>!^kD6Czkmd0mq2T=$-+s)8;i@ff6TpX&r#O4F!3QW_@C~{h;7px7 z3mmnlGaX)L1}c8#_o%SI*$hkspFo~`3Lm6+zn@THW!l=4e2m=lpXx9`9xoA>sMv-l zP?``MJJets>vR<(=2FFf_{}E=6rT)^%Ff}XX0YPV|B0qM%hrv|*-e%^{pC_~nc^G& z@yR}LX66d;#kIJXoZV}@1MV?HRP0>wOP_38$ul-b?qJTG8lcn+ReacKe%S%eCS(%$ zRB*JGx5wM@h#98%xo*GwqWExfoqEI!SNvCxUuIotPlTy2A|It5>4>>p@xy+$j>X$MzQt<`#{Bp_F_EfXL%fZW&oqgTe zkP2VO|s$_H1tcGYwS(P2A>3uu9|aI*O(N=>xBHW z37j{9{3dX81RY*sQWbxFGVQhcpFRq{>b$`H6!4|s>(WlShnb2W@9USdrrXq~f*022S&FY1;FssY z-N-xn{gyjZ%&;b)_Z#Y$Lhv=<)4``&?qp`Yxmxk(hWq7zHzd@2`jmV%d20M47FduCkjG2J zH7a)SNWXlGSYPx|uOW6riYj~?%w)x1NcPKPH(Gu@4)qFV3BY5IX;#(-bFJc|()_Y# zW`d+6b@Jc9H#%Q9$jaJirYQbihF=cOvU~=3g9Px@_;e-tRK+vK`sIO}66AAiCBFgu z8>XIo3cpVASH}Bg@$3Y-7kno8%eDD5#fMzsm*ktV6Bv9J`0L=CoT=8VtW9RR;`=lG za_tN6C-?P|Y7Zb^`Z;5UN@!8ebL zSH4+Uo6QZ1d#?4%eYYlvE6N*L9xFiL0rGf>xKYL4GSx3{%}bDJh@Fc=g%dg@ZgIZw zla;l_%vAijX?`i6pCG?t`~R)rPYd{P&(3JuOg>BTzzn}U2wnv~54;KZ)}iqSCo5~K zxk>SBZp2MkfX$E%MHYZ>v^>^@A)l@IW3&9ycp=`Wi1L>#k9A?lZ&tj~&3^e4{88{l zNc}_k!@qS{yuu=%qxizPetBn6f_xn1AAsLB!V}v&nU!^$nXCBcxBBI##r7sFsmXf{ zzbAfWZZo$i-hY8#4uNk%>g4H6{c?37Z!@Mlu;Lhz;FgDsCW_RP#Y#s6G_ z3kkkE%KOWWiAX)x4FM03$4kU~6?^4!zr2mu07}F%9BPG)jcpdLzS6ixqED z?3YsTW>J0tGcz*YpJHZ~D86i!Uk-rt7Blq=@zxmf9_#y)FID{WQokfDPms64SAh2c z-#Q{bhsXl7O!4$`%f})!Y^ogcBQ~~*z{hf>;_Wv0WgIv^*G)d$^4N$9JU|{R9z=y zJTIE&+NfOXDps=YomTEan)y+=CMwssN|fxr!%94LQF+)Omu8icOYic_PF(pPkRH?Q zCWrmy=f>xxv$D#~YQ;O;gQ3Vu^g{5q8Tsi6NcaL(p4J4&V>j89s@V4T`o-iX$RB6e z*wKvb8Se^aWvww0#pmootB_}_c^;}5yrQpauPRKL;wyIhl%QacPfhs1zqW7u zOAc9CYfZW0r4RV!*1`l~IU=72UfI_Z+fkpDRq0xz_@TXiIR@Sd&jR@j0gv5IQ=w$j zhy9XMWSbkxO_ambSyr00iZ6cDFP9hF_JjO+@O2~NL&2=9b*57BpC0$iv*2u5$>&J- z#P!YNv)5T!>&-gFr#7d zrouOxt%^^3-EzJ$NZyr4(|m)uP4U2+ez^mw^JD&TJ~X~S#&g-G_>*t@<^0tN!r$v8 zUo6x88-}aQY%tpupYl16`ENR6j!)bo-Qi0;5zVyp_a8ApUPbD82Uu5EgbhqM7zeeAu%&r=d&n6$Go{V+o z9>xFt#xE1VIk&)OXE?dDCdJfuD*op8=xc*BGvwXDD@VmwDN(zadlkR^C%?QN%?x=T zY04#Xr?^&{`xKvb!Y_@=?GhIGQ1VgnMJA@cOYtGUV{!^SmYFfNQ@>yF#((+c4RFp} zF!f26$0kyl`fkPl{l_oot+6ws?hA|6n& zkDit&w<4C4Q;h8(zi>Xt>0zuj4=P^iPL#L6xdK5x9voLSwid^n?Nxk^H&HIEaBlGg z%VX;sY-T1!)$?o3KE*E$CQ663b_If&$(47=WBU}?JUy&<@A`@I z6gVgDSv*&hr>U#D);ywk#|DWqxYDjbkk_Q1rk>)p=2694pPeW>!8vhHezi?Kwq(l8 zJf?V)Mv2mKoy`oN;^p9#>8j&jX&zVHeQu&G1n1;8`3O1S!xFuddZl?n@z0wk$}Vu; zCh`j{SLH209xoA3s@Nx5B+C1UWr<*Hd&cS(sM0*8c;x&PM_!O9UxIT> z1{JTTtW| zTOM0cCx1cl0sRxDmEz~s;sNq_@i?Sn2M4pq~0d`{wevnwfSp`zkn6o3&HvEAo8ZN z#*39cr~6fIURV4C*1Xce`AH`7^T>5(%FP>!pNmC}x!`;Qo=k%6s5^VVxCaQz`J3jaBDe zX5Lo(K1|I19X%297T^(`dc?e=_;Z+`tiRd5=gl_ZEOK2UBIaGiU&f4M3viYQ@_OK< znwOgQ6n|-UqI3c0`+#w-i)X2+QvBfDL>URrhn~atM)I()^i*E0_`{fan*q+nT=J&1 z`TL4*TbL+i;H*l?Tau@#3t4JDP<%CJuO0yBK2P!{;Hx!XZ9Y`|7RKLpJkjG2JM=CZIH>&RzyM)Eq%O!`gv9-9YtkvdY#oM6DS`W?^hkSzN&TQgp^NHeT z6(!1d;IZ>gC3jYKnfj-SC#=Ggu+`}$}BFDz6*R+N_@>b zD{GbcOz}_36Xh5dXFaNv z?e;>FPqW;aj$UoPRD8pxL@5Vni^peWx#i9>z-sfA;&ZnqO4{wVcSimcxwE&07xI|m z6SgPH|L(B8Gx9^=*sS7ow@L%#@e=X1iXD7sqP&6FSkCrJcVDSqLMS!I6~Ex_M9IF> za#ks;z_GIIoOi^0qj>#$6Xh##?p$HrxDp(DOPop9GV`tCzwAnsc{>~)0msIJ&G8vv zyiA~Y)t*H82t3y9xJr(C)!HwobG}#n$lgS`=3m96NkzZPi2gu_k!mnaq z^(4uQDs~uRQBlTz5{D%sQSk%*B>5dY_Rw{Z!G3V(GOaO5ia#1il3sh9%QT2wk1f}j zfa3e>CCOdj9MH&A?9I7yxa=V*Yb<8$f%Nj*RwFA-;{*pfC$@)2VB z^-RV#ldpW`dL&V98Y=!!`y}x{m>@aPfk!*={|r3NR{X1uNzxtsJI%+bqFrXrQT*I4 zNpb}^zp%^RQ5dO5G>@2&;+J<%l0t9}=h(CBP@6YWd`ZtFxgVULAtgVLT%T&hG**01 z??=AAw2w(_d4RA;8t{7>;?{gUMGXx_=2k?Yh;&AEy<9h4;f_u5m%C;>|v;Nw+` zQ)-$hK6!AGECc6{;vr^ol8T=#fa zn+}Rkn~f3!&JHrm@&n+d8O9lDl$r|^pFKB8N*}jZl@C_A4EL3eR^u4FJ)rpFd8qfF zu(My}#g^;TFI2o_VUk=A&ay*30z8sooN-LVbW*%@Ns>GS&b+e@xr}_YafS{N(^>H? z%aNHUZR+Gx!OKP)XAZf{bWwa;4oU<#Q)m6RNc#H9GK|wFE;C&fzb8LQ20dl3JNfOF zJ8KqYrkmnV7A46`;QYWIc@Ft#Yz#qRdOq4b-J|vyJJYVvnv#lKqHfHW@p{#_F@`srZS?BuU!uG~C1G zZ=RJ?)!|hF#ZTLavI@@o&D48?mya>dI0CN{C?48^dKdhwDDPppGc%7@2^8c?XUZ9`5-47LSCZ6y1{+_Z96#>qFCS~1 zu>f8rQ2dUaNiqq+t>I7=>hSMrXU2gu`h|8f;O{>>yg?^)YjWo&204l^s_b3r9$gyPfQ zNs@lx>}s=8Y>W>}l#Et)zr>7Ge0Eimq=Uz%mm7c=YhG+dDL(6iB$*1%yUz-pyh!sR zSF+-hKTeVv;2h$RyTA*h*T2w}qU6YahAyQ%>>0u@oPsn9JKeG z{85?iD@Zd=3sYbwDqe6}K-#=uCnFVi+P(sFh2rzw0eK#rr%JxTroJWqG!&RgiqG`o zV`qo#O(4%DPglL60+XruxWs^L2WKfH&j2q>Ro%Kmlco63U_d?uk7cH(7;>irQfRUj z?@&J=ZC|u^m8oA2UX-e?dr^QqULvkkv1d03$YjKFdYZAl5L=vToTjqaT&4JL4FmEl zIG5MSCtB{@vto0#;;)7R(&8n%zCk_-yd*`PNQt>d@tyw*;HSv#v^+V!Y(lR4){_-q z)-)i8z_}HL#d8>bXQm`sb?ZvZwTfr72uR?t-HJkf3Hc~>6H3e!#XFuKkQ>4I-Y@x; zwfR)V|7jJFBj6^=vn_WzPbKC$#SgX(NZpt1hn|>vZ_AxqTw;+I_*kmca~kYAi9t2&}2Kpra*uIp9o*PU%FH@*r}~-z#Pf)lok1p$>668Tn?W z;$8a$r1F&nxezBx{yBL=7R4 zk*9zcj55w-X@Qxo_}!NVq{$n$#E^e0DZYYKTIu5$&*#y3j^e_B4BP&u|FgSfb=MDb};0&*PuNmRy6y}9Mi z$fCq7Rs8B{0Xgq&XX8~5a7-3B&&n#ZOz|-@0x}hxT@03pOD%UA|5X9N#6wRw{n=+<^3W$LU+9A@$XI z1ijkiD1Oa6v{~S>%ya-RO;@AAQj@Fr=!F3(24`!^%!Dm>&b!p)Dc)^KK=y*iHu{C} zN+MEs#%rane8pYM1M&iR>^VCF+!00!wXlD zl5GpEYD;&(ZlXYZYI6S3vTr zZP_EgTSj~H+o*aPKNFz%oSnFa@7t0c;{}(g-dI6^JXQ|OIu$#9R{+0D?red$8HXy+ zeJ%WEfa0C@1mr95*#7ChpQv8WW1F`~~BRih=0P+-73m2Hpir@Z3K%NF?`^0IHi^20#RqFX>i{klD zqtXWF)?MH+e2iMU3S8II~4EwMnH~%bC)oA3(K9ht-#!=c-yxE;{V7=y?JfEL-D5X2BbYWFB4O5E+6`E z-ukNErMR~mJr;0YCh{hh>#KUV;y-;5kn8@x+;xxQZ+{$+ncyrDOx+-LXC`5%lDS8% zKkN5^yaV1M%G*l!|MGo`pZFsnbw0*BEcNjP0OUBzosze} z^|0cv{}qtt;9H`+DN~PS+x3W&<^Nc@9WN*{Va!YW=SSsy*P}{ipR_U|nkMDEsGJv| zjNkUhRM_@}U`F!-WD=Dzq$%_8%Z;#>Ve@qTXKwN1GgH7Hx!RudohRXX=^Nix3 zoe`9VU)dIz+Z(!rFKc18$Me3-98mnFvx0IIJhmm}uz39|`k5VZzQR1K_Ehb?Hi!U!jHA{KskmAQ$ z1!WU>Y+FY!@O(XaoNr!KJlHNMpMZ0p1~W5Is(krlRO-{Z z=CIxPDDEpe?@AL`EkKnxS|2Gei$4Z3jbrt({|DgO8jb$gxheO#(@yyIz z*BeTn9CWH|tED-jWc$m4lJbqcI83u0WX_~`Rh)@mNPbiC$-{!O2b{kQO`a(o{5j)J z;cqFvW<*f_RjCgrZy6t?GWE9=e=RvE-M@8G?*X3MFFpjuS(!zTz2Io^w-h$kzL&!f>{P3Kh3ZAx{ZHzjE8b&CP+kY;f;4&8+WZT} zGnNPC^dFp4?Oclo$m1pAOBK5?Cny&qmem!fXS*Xd*Qq;snZ8ndYd$&-;5={gA+kkR zwJbo#6o0M=6*)L7a`K6m$JQ>$zgGOCRk&wA+LxNhF9XjX8}F86X6BpYivJx6%9Y@} z#pFFLk1chOf1~&r6+x*4=ej+4H}HZn@j+&0W`UQSUE{UfJn^lHZM!Zguc_GXHa0dk zLag~t@je@a;{VB3(>&D0(wc{g4R*-CSA6J}pbP}(nlkxCj0w=YAKOUlhM!S5U44=ZKShJh`*J$kb0L9@rC<55PI^$2E$X z;6)iG_CcCV{37|UihuH8P%i()Sxvfusb|Dzv6=dBim%^i`9RF1xVx@Dl-&HdmHbMNU0Qk1YX_Ss|E@okJpQDW{4i2X+QBB8I@e!H zw%u>#0!Ust{FtHF4k0r$SGfLGa>fBG`BfLzC4wI`!?G{7VPs}54^YN${68w}>*s=! zaRRL!)`J*!9m591C$11{{#AV43qdLT)lUAAcS`^t6rcPfKdJatF9qd}-<(yR5crC^ z@s2Id!AL`A!~LjNg0kaxJ0ru?>wvGkAl@m@%v@;_6d(9{P^SH1OCkBQve=u`+uRxL zWwYB*NAa$22IY5f&OY;67J}yvS3K98rg-Ofg3|9#+p#9U3_LF_K5@o6?R3RkRRv`R zIG-Ti(+kMc)s@LJF2y^35R~=ce74Ekf#*9b2CUQEntvRW7r;5u&h^;#@{t#-+_Awb zD;lGC+oM7G1DsU>`GvK3fIMCzJSw*57eN{Rm)+sD7_q60&4`Z>cqp&pqmN;j2F}Wv zd0LaWFD!%3? zRDs~EVENUqzLq;JMS)3D{Js-G`68M+`6%)+@g;Yr9#H(n-|?*cWAjdagXOWkZ{$J6 zzy1rQ8JwK~@)XORQ!Oxc6;C{g;qkw=$tBN{bRVuvY-=R1Og+Wh)u}5Zz+Vqxc5`R_-1LW}%afXT==cz0ERct%N7U`T7nKKo?#$Q*y2j@-T zJ?lWOOGJ@rp!f}ey3+cj-Je9>Q-1TR{Yjab#pW!<=hv$%+2FC%dywnyVX@ z%5IhVNbr&j)fp%;XDj~y7`qSfDyl7B;AW2d&PzoD;DgEik)lMy!D?ob7sZx-uL`IuAJ*{?b$Q4XJ*fCQ;`%y z(PV~FpDwAp6FiWa6vYqKisA39czm&Nw_6&#mf|AzrWmF@oE3Z__@vO-TJTFXZx}cSjfC!^3^%ay#Xhe+(ZE4^oZVv{|qiJq#2R{Nca5unyIk&2TRzeoM{QW*}sm$?aIVDUvnjQ z3=@fAAGoh@*O$M@kwv~1N{${W61&H<#%(FB&KVhnzLrY18zT}WvzoM8DcL49hIVkl zLgQC1r-=!ig{kUURcN(Ve8j{U8W(2T&noc8=?Z`0)Nps~4(G#aqj>g|7ajT05WvWJF2kNS4kMUr6ufdf1z_G3w@oGd}O}J zGOlW7+Z)jG3MGGAC~_3btZK5elC77BY<5xEMalWgM9yTn+)lF~GT+H(zOSp2Czp%F zI+(7^b7Y>co02grM1H5zT;s?!zV1pc$q|XgGI`ww{-+>vwIf&idMNquYLTeqFLUH7 zN3QbqRPyg!kvN`9ZTW7bCwnP5B46a?y!C1_02w;9y;a!GLJ51GH^6IC@IUWH<~Ui& z@%2&iuXQ4^u4FZ7^;L4mh8XJYGwB;HE|7O{nyUtcd&PH^)lcz}n`3BbxM{CPhUI=E zjYE;E8-=?=jWRM;Tm2QUzm1>6mT50m1^x^jid@q%+-;uE$XH_yP`u{$7)oc3(Y@f~ znCJEjzb;g}$+ZS5{zG{T6){J*;3DP?nXetIF6_0|AjN;$8AHdIoc7+y~|*B9@+*fQIZ*}haI`#dJ{3BH=D z%kfI?d_v?fzOY@H<)oPvfDE152`cQ!QxbMH_ZwFWvnDF}+A}e!8#O=I$ zS-ryDF+mv_S=J=Q>pd4k-}+6ul=G9$Jllx(ujm!-RSs8JlNBHPLJTd4lGJhP3Yh1_ zg&zrBe>v6^#dn;Fp^s{sgbqxUo-F38FAsNn0&z2#s`wKx$I!@tNl$U!uVlWMd2ZM6 z+d`@5TGJGddM$=3E-?u=^SR6i8jtz&dWYW};(69|#V4PRp_gM!^7F3Z^61XIpts6Q zfi*+%``+X_e5`4&#SC7Lc~S3hw~?kOg@ z>(pD!w+;zE7CH%Att%Cu_GJw9sU@l72JU0NZK%3|Z}Wp=0g2r)GsCSpD)z!Tu6NZo z?NvPy`y*BOONWJfuePPuT*Y^O8$;%$rrocM2iIwei`=ew`gUuc;%&c=p+}ix7&Q39 z%*%&`yF-aH(#x%@6o2N&7%He^+Mgo86Z&c96~ny!POq@8R(!@UF_c)bKKozeXwGq32xM&H#9*cq~w{dw!3huQ?X|>Dbh>9J@>HciJv% zq2m4iilMja$z2W=o%76hkMNFa+HPx+;y?WpL(kPW?bS@de`daCMEEuC`A*woEmr&n zUghdY1Jhp36jif#g?l@)$6BKJ)Ua4u+0e8{GzwoBxp$=6@4ePi#S_D0scj?E9(0dt z=Yzz2l*-IrYnkG&Ma0tY%+Vr@`t0+}_l@#SaoRpBQ}MM?u{7s$(;m?Xz6gAzcg~nE zSG-dombNp;8xnYP=KDv6yAz2s()L?fihmswODCCQ@FuF^S5P>(JBJlKTk&gyvDBrp zkp2lWy~=|2X~;x!b3fM z>HDlril+>VrF~9ja64>DeR-|aaCek`2J_8|hmDM-TbScO;@%u5Jk&73e2d~=kB+71 znPW5(9x`>Rr@mG3N5{p|7tB%kUA{Lp+#Mg9k-j$o9x4&rRP6c*v1GO|?GM$f*t^~P zdiq{piQ@ex3r8alI1izS+#~ha^gX^(CBL64663;Mx!aMv=)IErW{5<6+m$;Vxzj3B za%4s%Xi2Of~NqoH?)V!lK1PFKa!I_CJ~5qN*_4&m;+vJB=s6@PDjEY)ab(jtc!GOy_G zy&!YNVVC0hi(=^!hoi)t03Q(^YV2jcTk#f4`To~ho>?f5PYMq`p3^G=;GrV0N5#I9 z8B3S9k^M%|zlk1-kj#2Tuea;=RW( zKdAVIeBM>&=nVyrdnKaGI;8jyg|XCzIa*6_Lj&JFB)m9uacuV;R($ySSn9|e z9mL>1=A}+SF7+Kz^6&QD-ja zIsO}=AF_S-2EFUhXl=*W$}8~>gWKc`4TI<4pF>wGsWdDBsm_+kw% zOGu~9HNDVxi;~85B3JPq;dZ8c-HIQ+CzfV1$CUzpS#^Go z;&0v;O9jjm`FOazpi#K@;4ARmtN3RR#L`CQd$}bBoX0gpd57;lC0~C~dq|-W< zp6z>B$;78TDHo}d(T}h+csusJvp(8WE*Eg+fb3dx0EbCDY6rv8SEjElGfWw?s+zr?&89X za|qd-<;u&{Xp@!JJBmN{Tr36Jn}n|tq2p_YaCgpLM#f6(UB&-?o~zQ#(N+UKn^yT( zHdX^2RtCV`Q)r!0vAtj9Y7fWmaAJo`Y^WZ?n|e?2b*Ey<>R{3xRe7i`!u);3KY1yZ z`ZB*IT5{G6skg8~H67+3C_dzsSX#)u(%~(@FID5zR{Fs+mRDA;N2Aq;D)xa_W2uy5 zrw3$H4QQKx#ieSv&mW_SPI)k?g&C$%rSYdsn_+zK})u|(QMe>D> zLp%t)jT+Fq!unY8F>l7wMCLrI&*sC0hu&5=^-mQ4>aAG1hWY1JdFX8=V}5t5GWTx+&k~trXoE6%- zKXREPm-)U{GVrO$_mvE#X?>&QiO*u`Nj|vTA7gLb&x_7xCR$$Gc&7EO;(1@h(r3*1 zX=U>U;H}gMluYZq;$6OqC8HC!89KZ!Z3g$wbf)#4;y<5@rOTN2WRCj=_%b~(e3|ci z#gBgzOP!dvaCjo~rOxqK8h{L)?jKax^z#z-H}A6C$2LbUal)4PepE8*dy(7*ACik5 zx!CuUk`MjhN$FjG(eKHhm7MpJNbXN@`r%*nFLKgcO!{3U4zBD`BX5ta-;_N4M=Xut(=f%6NyIWy-H#Vrzbjt)S1hey zKE~nAnJ;Of_>w;86$$^R#s~g-NeDay7BM)Vh-^qLp0j%*a@1MJfJ$VjSJZ{B7r4q;ZK| z)Y|fTGZ$Gk6cJbz#1^t*XK-wxSjPB_)pHI-Ato;jM5zw6nY+PD`wS z;+ty6(GcdR9e#!IP`h}>66+Gho7Rb=V&+|(tF0$x`j@m<^SYJc-Ji z9J^G-F7Bu*NQNN;+zeDd$)|EUzy|S zip)$0@2npAi~QiI)E1ypo1$WWZWTwWIr9pb2vIc9I8;y0f8l5VQrv&5>S zc&AQrbecKlhJ%k~zO;uLO1spmt9U}^II7vhq>~QsMz#FQdWM$s$jDe`U8eXSUE-)W z^G}##Tww>{-uu!rtDfSYbc>^_n8$E~0eCa!nLRCUPEw{-U-4Ia#L*V!XkP$tz&xv$ zdU?yT8YupFuQ+;$Ii}--w`HE)Tk&kGq2l-UiKB0sZ)VP)3gW)2J0h5e`av?XtwxI9 z)-R5tdvd$8!y7PPq4^5ya>b7gh@;`m?{IjpYCHhmo-6LSU0RJ*?4Ch!l*h3vo!Aa` ztk+G(FELFNUoj+(Ze@O)PXczNwQMTXXUDv$;(dn2(YwsK{%rG>)wr*j;{Og8zR=;k zj&g)IMyR=x$3}|e0dID~klrgv3nizG7Kv&Satz7cz4kL>xz$q1T4Up=aW9j&CEk{! zkYp3f55og0niO_sCmUeUh3O8&A!CMM`&i{RRAKca^zLM(Mqn|EpiJV zPmBVrD#s|9w^!rAK!#53I2AVMpoF2WfU%D} z+fQvERmu5>MWT$ma*mUgIll2qZsQ6cuElY@d)1^L5*68v=pLP*!k)ZV!qEA1wVjm_ z5#9zSDw%Rj7Ck{N}hR4BxY1sm$Q@%ds5^LT*{`~dsL(j*=!|iKP~b+mt0qRr}j!E>zx#dGCs{t zb7{mZrx48Y%~3M`IgwZmncLicfX9P|0^s zi!9>GRCT#X$pLSQ?0iwVSjhu#iJZ^vI+EslR=ShVbl(ys-+x;qX4bhf&5>!orAq$u zj>wVRQdLbxLx$BhJK|NSMOdY8nT~u{BEz{0&yAenY-5HmQ^}9dh*-GB6S18RbmY2^pO7`T| z0NhL~Rhpj6Rr1G+Nbd%{R>^~(N}BhD(#*9&kL&&nY|eqVrkm zj(Pm%0+^3K&pVOhxHDP~Os*BGcSINHNbY{e@Bc>r>TI*JGS}h(#`eqYd|#oG<)2IL z-sVFpx8+>RtAiCOS@MO*cepr-yxj8YitCho=u45$@^$9QP-WD&Udc0GiR^Y!S*+yO z=R_XhE3kT+8ASH0e(Wa4>|E%_cJTmL3JRAmo9x~JaS ztimRJ7e{|{Dcs_OEv0Y5m)2IV#7nI$ieLYI9PR7J15x>&hX?c_=F3{E_pW8uR>gn# zA&$ECH|a;F;76Hfw$ZP6);7hj_$iKFXa1DK_fmHF@;2T;dw#1cQGD&sakO>-zXUQz zz2a8p+>GH(Imxn06@UAeIBGJ`q+HHB_|4#LtMKiLxBp)pz0G_tb5wTjV4mIH@>0*X z$`n8ND>pa};<@|$+(0er7VwUt6#z3bvi;zwedePXwOqx<{T@f{2Aj0giM^9!S9DSf zm8`HT6fgXP8!MTkWdK#XYpU}divRm(94#GU(nk(IEZp0X6#?*2@z|+i^Zu4t^q-@? zxt3#Bc2WmnrL{}(Kl#;v{!r<=2=UJ9Agr`@E55jr`%su~<(#43yMUevUwMVv@0Hdb z#m{r^U6)}z>CEA?!K-Fwui{h8Al<~AKRiHlIQ6B%_0W-hik}V(QtRPdxn+)7WE&(i zn(tSo$)$}^JH;XLCI;i-4R*=>) zzlZNSm~;ltyXsB8TIoBa_U zK#*pSmL4*^Kfc6#bz9Ztwc5H?@zP6zbc#8D5z>A!y$Za&I+m-g>lA;B+tC}2k-G|J z^G;^IrmYp4dc;$UuUEV>Hb}Y5kMgkupTa!1x#~H{wT>y?E*PZGm|tLyH}82A9l<;_ z+b1K}xq?Y5jv7RaTgUs_n6oN zObpUT%ug^y)51OAZPewOXB}6(YjTk0rOGW1Z5ErD=eJSEJwE^*DiJrU*gI2#^gYL- z%NVg6>4}Jfc4{0-fpv@G9cu?^(Rf~ofo~+>`OFJDsZAAHw<`X~rMx4|(aDW=mrUkG zoviCa1*pinP4O{xgVcP2j5q=xF5GLVEb`s1`1_XyX%usG%Y!!pcP^JA-yKR8))$E( z9gu$N!fPbRAi+ZGP9@WM72*s|6Wt3T*++FS7Fu^HKK=3_UB~=Geuo3^kA(ZFUKk$B zs`!c~LHdw+EWdDqcVk}ALh%CY9>s5I7Npt}O^W6>S@2%qLsfr3fpxFqU$h9)Nah2X zd$w84W<-I(KI}0~m^K5(BdTunN z9`pIAihQGwL}C$&YLULjlbM^XV zxdHIdNq<7c4&au)FF6*CortZ2*hZ?aKG%9u@sEZDDRz=fsReHV-a!?|TvOGV6n}4Akk&HC>>}{Xs`HbI zmrMxKPUe_mgePATxL$rL*Lqg*!IOh@KXXhi1#eJ2_2(4-V``AzW{y?p!Ru8|{dvW2 zo)M(K6>m(x{5LZ%D4vnQPmjqm=@zNC1+RK!UR1p1>>y2Ijspqaq8bl?hf2gL75mtn zAg$q8cSjm=>{@-8)>ORykyi` zZ_Q~uLSIq*%pyK!inn8)r+J?Bs^XiM2C3c@nb(Y*HLA{EQ@q>qAPr~k?stpo{B^~@ z%nnjEa}++Lj=bmV%;a0A72lN;q&y>0MY{_zubTRs zihs41AK{AEVP2q9FR;JP#w1i(YZ<82k&Ze5V3bF7=Qz8qVq zS2iuQ-ckH?agc6Nn;I?L8z5L{y{q{8O+gAylaqj)T}5a8h2AG^vCPvM#XD~e(rV_o zq;Wf%$Gm8=8Us~iy{Gv3k|5p597~jeUm@HZ165?bulTXDAbr6cMH{@6aBrMJk@bP% zleqgcdAj5syaU~Y%y@UNBI`rN{kwzIhdJ_&`+HO7>+}M&>#VbiKfE_cS30SKHv-oS z(5|ySQhf1&Al;}^?=Pu`CXVvr6(1{J?=Tl2mHOannF)Z0O2j8B_O+uyYBNKwGvur< zt;2qMn<}zCRlNARAl<_pCjoptbKVheVDdWaGsWBA5Twh~frDcl?8w$A!o@y~7w zQaW>7#o*n9hbEWvet)6(u3Lh%(cyUHH)Xzlk{X=9-uhDUuD1v2LFSn03ErO9t5~nE zv-Q?jihq7*kS;LCJpz@K?#zqzRa|VHQ@s41APq{FT?HQpK2befi>jG+0C=cGoL8}9 z9}Ch49P3W|?Tpw->Ro4p^_}AXJ`to#X3BnJM>;U)!{p_xI2zoS5Na>CzE|PbJ{_dV z9FCKMis(=d=W`fZet;LY{Xy~6XM^+r^DiBqj;)PQW3q~^9~F;yAxMj7NpVH$4{%j) z!$>tcaf9`f;xC*El7F^ISlbX4@B_lV^0&eIS@Durf^?kuJq|z0eB)@<_OQ|VMe#|m z2WjS&CVlGg6V>?z#ap}?q)(Y+_BdW13c<&OKCp^acK@fi@eY@eIkI#D_#B$+-!xXe zFK@DbRs77EAU(+(iyMHiX1;l>8fmvV03IqKzp2=VKHzgUS5~k@zx^J>j#Dqso2}m! z-}Mm}b>{etDEKVqTfA?YV#(b<6wmrJNP&6YS_tEXdj)EX^{3)#Uj(Uwxw{s^ddkE1 zp4BwR)CKSJ#@hTR=n}Id`DuA8UNsunDd9T+#6VW`c~^7 z#cO;Yq}!O|J6qtRgu4X@{9naC`Y}j#ua+YNzKr=co%%MbQt>B#3DPR&Sf3HYL8b|J zU$4{Cw^@|pHSOQ{E0^Z^l6UYj+U_qI7n(4To?c=ZikJSuRc6I+6YdT#V;%qxl?YSC zX8j$cF$=s+?MG~Cs6=2>VTxZ>8Kl$9v8V$^z!Whr9Ur>Iq^Fl!H55-ZkSsPTN*r-wLtFfU@ zjSWLInZ5VY8vxQ-{G|X0p+1fBW8Rk^OSitLW8|G%i++vtz zhH3cK#$Skur~mQBQF^iQ@${pgH!iD*{Y91O>E%YG^0x)!X~<$(pASA&ygc0D0^cOwW<|S%#zWVX> z32!cv-^DSu%_1`rU#zsZwB3wRbW1~_cx_+9v@uhBveKrdMy#U##_`l@iL?&5w8Z^} zhwV#@IOSJ2ji)U33%NA`elRD!Em80EHY2F~>&@foN%j~<13!}fjNDSiZ!zMP4{sGu zfu%A#+x43hEt#Ed=V!B-pggsSr~2$MwkVg=4|4igh1aGV%|u0CX)BcHz&OJv|FoCP zwdr~@NzwJ~g8PSd_iUf!eY>rh_oAHV+_I*a@F^Erc+mCbmO*U)Y^P;^Td*_Vg70yc;aY1pQuLE)>9}ZdAbzse|83P%_fSz)LSSwvO9Ew{qZ@;tI<@^3w`BnY%i~~PWsK2&m9y`FJ#Kfkht>T^XfVs9JbFhS}1>J zNIbo^oJURbm2KaT#LJv&@n%cqM-Gpt3+(gw&H>+u^Y5Kbvz4N+^JrL5+`n*VQ*~$wJ*_ zws>6{`wM~g;m9@HEBfp-q4<&m>c{_~9Ta^jO(;Gofwu-wwDsDlt}#0*`teMmSfUp1 zBcQ8Xy2|XNXwn>^jqAB>l*E6vV|-nl^NH=HV( zX!zR;yh?qTxb-P)#^W_Lw@$?u=Y7|c#j*HNqif+so z`b<4{#OGQ+WUWyoh0+FQ|t4b>?oyV|KPG5aVwWwX$mI0G^~^d}yT z?a)PLUq$zC6S|J6j1R3s`ziX;cA@wTnM*Nw)6T#`v%jKeD}LId+dk76qP%f!Jl)G4Uygxq#XhU8 zzGG(@LzVyT`gr<-Jr=u$AILttr}o*#Fy+tQ7*B(5o!A_a07vrMy|gZ2UD@c(;5Qqg8O`{qb~ygK=9T} z2ji)IjvOrb)fDir9Pcz=IDV}1(;tbaqwKNjApGKs_;JdQe>|R|R*6SBU%)NH2_lqfZaGo(u`R?!Wdn0>1Vc}P@=l&OIny`JoFx@~-Z~lSrl#P9JmKn3^UU~Yewo)|lLgE3e6 z_y6LZVPAtYhmG}zcboMcKTr9e|Ba__+2hFteD@I9Mgv zSf>1^mnM+0-n8!=*w}LT`p$w(&dy{if9^6q8OraX692X~y2Wgpv0VAT8zfNoV)^C- z(mw`2T;FN78Cl9lH%_3B+2hK?fI@iw#G)*WW#^~F$kx7D0$sX6PBwfG_Wb24X@9nT zsj))&x-AoEJbO3&F6{YRRI;L!?YA2%mA|}A0zJeYcUDyL=h74Y?c<$k<&Mu$zFqqS z8njWaZ1`>L%O>hNN|~`r`2n30=o7GE=CLY|zcLD^rNAM(lvdfKJ>NQN>pndCK2BAc1aYk2?)swz^)#=PQ45Z~}eH9(OYMp6qv2Nq>h? zp!^%d5~$r~Iau(6#LEH&_QBoZhj%ODMxhEmH8O$baxf}T2uA;Sh5LavJGjCqQvU8S z2~@%!wMY0S;$?uDN%$@={NwC*OtG%zX0}Rs3)^9AR=&a91nRI| z&OExNcf(KCwbLEO7Uf%Coj@np<5N@UY%XTMbCSM|>@>D2-*909{lXr#L3Gk~r}O?@ zRc<4@jBU!lv6!EEWil!Nz9akH6D?T`z~0~8Mv3x0mnG0N_E-`W?HZ0(SJG~?RQaLH z6X+`T{HAU0p4n%uBBHm$~VbnH7p2UL_PGtkHwV<<{>t<(oSc zja(xXEtJwuRcU`>&pw(iyi9_4Q?OrYo4V<{W-3$2A8q>F#4u~+%b zbqO?VheYWc#04F zJbpRj^i_Sg#5|yU_l*fOQu!2k_k@-NK;4329#nC+Y?3(4@IagBCspE%LyB(aiQJd$ z_STjGEgh_@$fd?%^JFIzT)8TpgR8Y{?;9yFE_4HzVVI(s(XN! zrgWO8Uu0j=T{pf|7}qL)d{+V`95Sg6w+W*;`!~+d&VE(zNY^RfXKw_o z@CbFi&Nyt`tbF}j66nV3hK3_4wrdy+ir0P9)I#>@jX<9s9lTqt)?ef2Z=tA5Ng@$4$alMAx$~6faBT z*!ekZ+@<`A#}a75&GOL`_&>zUqW|Ip@S(zSw+im|Bv-U<@irD~*qQUH{G7Ral&|>= zUwOC6xF2ln1f@qF8RHzRf8=D`tNdHf^2uiZD(_4&`_1BSu&->#->3YZ7ZS*Ko8$*R z4Ssah^zT=G;!6qCmpuvxd=K%`lINuVfbtQqa@~hLMz(EWKOKIQB`sTyKcW2FuP0Em z+vTe)8`*E8E>TBEJFi^-VtJWFqzCeZ*@bXdI`y$8Fug zj`DN*W##jEZqzdNcnyW`2p{JRn|1sv%CC({q?g#E-5Guw`@NT`y9GbjUsb*!m`J~} zN7F8RbNC)r{cFk>^3pSP?v(}?_$Kf}oi`slKYNYWmEV+-NZr|^0R_G;-4V5Syi-+o z{AuNnT$)I;*yBx7(r53!`MfO|^irQTz zKf8^0l+S3HNMEo=pEhpAJWelaca`*a8}BN=xlJPd$sQkqhDZ9ls`y>T8RZ|~!4#L= z$IGNDzq5+pX}+iYo1GFVkv$rWkiNy~6@ZU$}UbVo_j#;3}6 z9hpcG_e=L4lD?XKS!buxA$x0lru-FS5@`Z^T;}j2;m23?pDW)cHIYuT$87|Dsd%qa zkXB}Vp?t%MiPYo)8G(wUGK(URzW3;1|E2PQDSX+o$Ns{1XV2}V(yl5;-S|rR@ac(k zls)nT--|tuaFwZ?&ha^?{Lg88WvKKAvgZM`G7ZD_B>{N1G#Fp2;4fz;Qr#2Y&UCFF zY<#2q2UjLiE_<{FA#-W8JF;ZFdM>7w7~d-Y@w`O(gFWt{@N30OqpW1kIIsNs^Al;^ zgVN&wzlHraw=dE5+l=p&e`!%7{l*>_41AV&8Q1Li@0EXUX(Bav$V|(iPW1tPBwf;_IxF&D`UIyyYkaY6KOSj99;PB;^lki_Q}|8{Gt4$@X8fi6&^=s1c$B|Y;-ue7yt-1$jK7udyFZZz zu}A)qpJwn?DhOr9Kgzc|lt|g^aeUz0T*UvYeDkA;bez3=zB*Ee|E3>+4;79|72M#u zM0!&Nx8Ptt8LH%NH>j33JhagbiR6Dwt|Xj{PVnyBL+4}|%C|Y5NHf^usR=(`yehfd z4IWDy8h&~OH#Z$*kJ=PUdT)4lexj3pnDSlkNTe6pSSy;YA8SWZf?$c zTsjTluVBw7TkUU|;ZuI-eTmeWJ?uzY z`RNby{<6pZ!nYN#E`~BALiu_8-ME+7&kgy-k!6$g{+1bj<>x<{NEg`S_#pkJ#J=kO z2H@SoVMeOpna^Xt#H{)U2s!-xr1A1p!$C z-R7pc&5TyG*2_XMy+EkNAIo#9E?dojqW8Zl6km(RiQ>9k3Wy0j|Nq%3Yc*!bnV*``Yn$im(UD* zG`oH9ip-#*i{BBtjK@yndg8H3kp=En3e0##C%r4Q&P8beG<4PzRGjaO#68bL)N%j8 zCNQ|y&P~3VsOUWxp(-~?ie|j`-&Ftgdo((ksll7F+pKDkiQoM9eEe}kkrl}hLL($Y z1%b<$x;qkRu6{(f)QR#qcl`cuqL=gFbtGC@`8%%_EQzLPnXMEZ{k~*<0v|}({_p&8 zB!_03trcDHfzSj#lP+E1(iLVKMR$ED^k$}VCV%(mxHQLXt7!5^La}Cx&~pE3m##M2 zDf;=xiB$3gPZ!~i6wn=1&MQ1b$l^&jgXy_Od*y3=mPluwl<6GsBaCwY+F*niCYzC!XVn>oX={ja^*9+3fTBM;znRP-&mQ^gKU& z*#dik0n6y5f^YvOkMiFRos~cRLn6Jw{$cKqfWL};L288e0+C)|bWwi7&-~v1oE!<|y%&h|0<){~Z(m5H zE7@au5bBi8kRSIX=9}FV9s8S5d<<3IAAgTfw7a5D{2_D-Q`DS^evim=Q_VAbDBAjO zp;(c`rE6Wf*6gY1p?|BVn(NYBvzMZO@YFh8E7~ebXe9T> zx^$JhkE_f9iteZ>6kn#h2py>ClhHz7Y;(* zOx?p1n5ce4Pt}R?9TC6(n=q$Io$Px{rje%nhw(}D+Y4L~ z=N?VG^3P+R)gt1EcYDY((v@$Nlte>L@#0imsL-i2mVNf%h}%7%ZDc5)lafRWUNQ;G z8NoMXzj9c_t)5?L%vAp4+DSC~W%(#EI^BM!A^x0E5%R$xJO4QWc=yy8vsCcxx=Gac z6}g=t_$>AJuO1U2>(bf5tBu*pe_1byj90~@NAL;ux#RThE!VhG`J9GHRLmZ?H~97J z*N)c*YppRyd4J<1`j|aFx(h#zeg60euTvvE-uWscg6m)KQ`qzEQ=Pm5 zW1jN;S|rhK_L!UrKZE-C3nxU#h*Eoh3yrIk|F%^U{mdQk{SoPR6t8aQ>x>1;Ki4UVZex$`YvsL) zd-^(aq4JY?f$TTgyT`K$kIO4^uY;lhs9P?~MJn!0H;Kbz1KZ&T883Y1Tt9{8Vnti_ z6pE#|QG`I%V`GV;&3X&Pml9m69ve#)t?VN-JsyyPbz< z$R?KYB3n+zbInXe8}zSEy$3_|a;ExDh~In-=HrhWioL}(%A=3`UT9z?Q}?h2iqwzj zV*UYcU(7Wq3cnW~K6eVg^*^pLDD0B?QTQ)*NM)tZRd%S$-2c;tPV!!;QN#Z4RYRxL z-E*qZp;$ZFFB%cO!eLYD<}B;P`)tr>HUFfju*ym^r`;?b0b=|#>Bm6JCyh#btD|N< zLO)hkUV>EbZ@}v?n7pL>mr=er4qZqSCR`tm+=fNv&p@cXZT=rSbkp2NPMy0g!rp2# z-s%hv*)(^}lTGZc=G5G8d}mfx)>zXa1EDXU{9@Wq&pBHiJ%O9AoPD$E@kdU=xI_PU z!}lluf6XYneO+vme1upEl zw^=u|o=EiWs{cK(R|N;I(?4$4KOWRSp3y&E)j!@-KcYY7AMVcczfJt+UChUyKL;dH z`Dv4u@uh+)VP)kSYiSR^)N_{_pDFt8pd>oa6sr`1P9vt>{OWeUG#XwV6613f|Jslw zYWaqINCokIC?;arK>tx3ZQ0(kXn1$@5U)~he4*ms;)mKIj>jVRi03b#MJyZQ_xi-s zmj&S6i^up<1%EjriLT>dJa`d&1qWyL^Q(Jtrty{X|BmA8n?2Uvhi}Jzc{jhhw=Or% zDc@#n5_Nc!yU4f}2cHh#!|yfkrZ4xyw_Rh;FN-j~R>9*_lV~po^AJ8ecn-a7W%crV zed6g^#y85(oRCC+vByY6RJLb}m(dyagCfiPR{61$lBmU7+y~9KPWZO$m-Bj*HeGI> zSG4JrBQ!>3aY6a_=O@vaGqQ35(m%^SXP{q(#n@*n$M~P}vlk^%MUo%9fzsC4Y`GU+O`jI_q3`jqN{o0}aBi`{@Yy7T!WOfoY zecz;cj_*PTBl3p&RShQ3_(S>fl}R+7y_=tw?DL2Ew|eR48-FSvzdDKX*<-*S^3#HS z!BD@~bjmNhe<{B?H;Ha$zn_mku1d!jck)p=GicVdhL`y%A@f0r2AK_P7$~P*NFWQ(yS!bm?8NPHHnUYVv^NRveGH6D59{PU$qGo8iw-klqOO3=e$~OWAQI>`b9%^ z5h^lxIf&2$<3L3c4fu*jp?x;g$^LWgw5IwMT^NxPG%**7Movd1a=f_-rn z&kM*XZ|qN^z;}G3<_A39sNgrv@q2G1Gd4xThX+H?Qq!m6Z#|Sm=Jyhh+SLVG8nM|q zYcn>R;fl_KH)i#^8gJ-mrZC$1iCizun99%NHXj0okQ z=H(3LDBp;EX{_ICfSOur_?2IMYZBeWz7ZcXpTy4a_c81c%_Jk8Am?2$CyoTk7}uIdw%zu~PU8ug<|cRPL(eEq6E zQTdo6odTBU$;9kCNyR zdmK{uh42&nGKUp6EA}bMpZYY3zGgp<^8-IZymzx=UrYIVUve$$XI@UpNq?AlZ?GQw z+R88gnxA*d&)`q9a(=viSoW7H|LplB`doQ@`IWs_>*I0(@0JUrjtZ{zV-ltO;$^Or z1bbCL-dJ7bvwz{+0{czQ&WvEceS%-@%y#24<)8gEiO#Th4{nxt?@2IiyHQX1dVeNS z;|pH;Bk5zF_U2ScF{X8TnNeT)ynm7?mHq8bep-o_{5ZaW@~3&IRX%(7WV947`Eh(h z@S&2@R0S`OPo_nGnDmZQ>?0&ts%;W%G*kZWB(8$|DVf7Of*qW> z9pjxhFWc`hnk)Zhtz??PK9bXipF#bicBVQHDBJHeS}0$;ZZf@~{6hA-QvK4SV*6c2 zOXVl>l&LHKGHJMz{sH#8Q=OM7+wV47DPPtonY#Y%`M22bsgnL4qqXvHHc6(>*kgea zc`DWBUenVwd@RJ`2Q#GCDvzoBC?U0TWWP@MFqAV2(su<70as9P?~4l3^T&Q;@h z^skCDJ1Y81H=!6+iCrKX9ks{Z#2&MgqVc_iVj{dtce`}Ad4-~F`wGR_LL7Lcy33`z z%+8AT8Xy#tUR}D=r8|u-icTAxOlf>LkSeZ~ew^x#Vg4<4mC-&XJB+T%7Y$3M^XxH# z626qWM^&UcPaWG=7~PaVI5L@^WMTG*QTUY?@!gf*H71#7qlDR`PvGaV=W9K5<4T)e zZuU@qc4{*H!akO}^f9BV8GFZuxo6&|OV^oW6uqHHC>mMZR23boXoq5%YVE8v^j5(?4DX&nZc!EfHaaq0tB~ zK=1^oUB*708;l9cH!Vx1SNvi2Trl_n>^DttUXrD(855QNxgwcfiVU+?afbhw*pE^b zh#8xVNyqo;UVg%jfsva>Yrol;to+8k$y8o5%%0AL^dF~zkz15u)2H}BkQipK6$Aez`-*Y; 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 literal 0 HcmV?d00001 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..81892cc --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,2 @@ +VITE_API_URL=http://localhost:8099/api +VITE_ENABLE_DEVTOOLS=true \ No newline at end of file diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..912abf0 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,2 @@ +VITE_API_URL=https://sport.luminic.space/api +VITE_ENABLE_DEVTOOLS=false \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 722f403..bfd400f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 48b0c1f..b6832c8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,30 +1,61 @@