From d93c88c751d88000ecb1435fd5d98f0c11643b8b Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Tue, 12 May 2026 03:52:13 +0900 Subject: [PATCH] first commit --- .gitignore | 16 + Dockerfile | 17 + README.md | 68 + alembic.ini | 40 + alembic/env.py | 62 + .../versions/202605110001_initial_schema.py | 123 ++ alembic/versions/202605110002_car_catalog.py | 49 + .../versions/202605110003_user_preferences.py | 26 + app/__init__.py | 1 + app/api/__init__.py | 1 + app/api/cars.py | 57 + app/api/catalog.py | 21 + app/api/entries.py | 160 +++ app/api/ocr.py | 41 + app/api/users.py | 46 + app/core/__init__.py | 1 + app/core/config.py | 22 + app/db/__init__.py | 1 + app/db/base.py | 5 + app/db/seed.py | 214 ++++ app/db/session.py | 13 + app/main.py | 29 + app/models/__init__.py | 1 + app/models/car.py | 55 + app/models/expense.py | 58 + app/models/user.py | 24 + app/schemas/__init__.py | 1 + app/schemas/car.py | 58 + app/schemas/expense.py | 89 ++ app/schemas/user.py | 26 + app/services/__init__.py | 1 + app/services/calculations.py | 134 ++ app/services/catalog_data.py | 57 + bot/__init__.py | 1 + bot/api_client.py | 40 + bot/main.py | 114 ++ docker-compose.yml | 49 + pyproject.toml | 37 + web/index.html | 291 +++++ web/manifest.webmanifest | 19 + web/static/app.js | 1102 +++++++++++++++++ web/static/icon.svg | 5 + web/static/styles.css | 878 +++++++++++++ web/sw.js | 55 + 44 files changed, 4108 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/versions/202605110001_initial_schema.py create mode 100644 alembic/versions/202605110002_car_catalog.py create mode 100644 alembic/versions/202605110003_user_preferences.py create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/cars.py create mode 100644 app/api/catalog.py create mode 100644 app/api/entries.py create mode 100644 app/api/ocr.py create mode 100644 app/api/users.py create mode 100644 app/core/__init__.py create mode 100644 app/core/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/seed.py create mode 100644 app/db/session.py create mode 100644 app/main.py create mode 100644 app/models/__init__.py create mode 100644 app/models/car.py create mode 100644 app/models/expense.py create mode 100644 app/models/user.py create mode 100644 app/schemas/__init__.py create mode 100644 app/schemas/car.py create mode 100644 app/schemas/expense.py create mode 100644 app/schemas/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/calculations.py create mode 100644 app/services/catalog_data.py create mode 100644 bot/__init__.py create mode 100644 bot/api_client.py create mode 100644 bot/main.py create mode 100644 docker-compose.yml create mode 100644 pyproject.toml create mode 100644 web/index.html create mode 100644 web/manifest.webmanifest create mode 100644 web/static/app.js create mode 100644 web/static/icon.svg create mode 100644 web/static/styles.css create mode 100644 web/sw.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d71e7b --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.env +.env.* +.history/ +.sixth/ +__pycache__/ +*.py[cod] +*.sqlite +*.sqlite3 +*.db +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +venv/ +dist/ +build/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0359682 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends gcc libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml ./ +COPY app ./app +COPY bot ./bot +RUN pip install --no-cache-dir . + +COPY . . diff --git a/README.md b/README.md new file mode 100644 index 0000000..f8ba3fc --- /dev/null +++ b/README.md @@ -0,0 +1,68 @@ +# Drivers Bot + +Telegram mini app для учета расходов автовладельца: заправки, ремонты, обслуживание, жидкости, статистика стоимости владения и расхода топлива. + +## Состав + +- `app/` - FastAPI сервис. Через него работают и бот, и HTML5 mini app. +- `bot/` - aiogram 3 бот, который регистрирует пользователя, открывает mini app и показывает быстрые команды. +- `web/` - HTML5 Telegram WebApp фронт. +- `alembic/` - миграции PostgreSQL. + +## Основные таблицы + +- `users` - пользователь Telegram. +- `cars` - автомобили пользователя. +- `fuel_entries` - заправки: дата, одометр, литры, цена, стоимость, АЗС. +- `service_entries` - обслуживание, ремонты, жидкости, шины, страховка, налоги и прочие расходы. + +Связи: `users 1:N cars`, `cars 1:N fuel_entries`, `cars 1:N service_entries`. + +## Запуск + +1. Создай `.env`: + +```bash +cp .env.example .env +``` + +2. Заполни `BOT_TOKEN` и `WEBAPP_URL`. Для Telegram mini app `WEBAPP_URL` должен быть HTTPS URL, доступный Telegram. + +3. Подними сервисы: + +```bash +docker compose up --build +``` + +API будет доступен на `http://localhost:8000`, документация - `http://localhost:8000/docs`. + +## Локальный запуск без Docker + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +alembic upgrade head +uvicorn app.main:app --reload +``` + +В отдельном терминале: + +```bash +python -m bot.main +``` + +## API + +Ключевые endpoint-ы: + +- `POST /api/users` - создать или обновить пользователя Telegram. +- `POST /api/cars`, `GET /api/cars?owner_id=...` - автомобили. +- `POST /api/fuel`, `GET /api/cars/{car_id}/fuel` - заправки. +- `POST /api/service`, `GET /api/cars/{car_id}/service` - сервисные записи. +- `GET /api/cars/{car_id}/stats` - стоимость владения, топливо, пробег, расход л/100 км, цена 1 км. +- `GET /api/cars/{car_id}/charts/expenses.png` - график расходов через pandas/matplotlib. + +## Что дальше + +Практичные следующие шаги: авторизация WebApp через проверку `initData`, CRUD редактирование записей, напоминания по `next_due_date` и `next_due_odometer`, экспорт в CSV/XLSX, валюта и единицы измерения на уровне пользователя. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cec4c45 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7903f04 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,62 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from app.core.config import settings +from app.db.base import Base +from app.models import car, expense, user # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + import asyncio + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/versions/202605110001_initial_schema.py b/alembic/versions/202605110001_initial_schema.py new file mode 100644 index 0000000..a933285 --- /dev/null +++ b/alembic/versions/202605110001_initial_schema.py @@ -0,0 +1,123 @@ +"""initial schema + +Revision ID: 202605110001 +Revises: +Create Date: 2026-05-11 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605110001" +down_revision: str | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +service_type = sa.Enum( + "maintenance", + "repair", + "fluid", + "tire", + "inspection", + "insurance", + "tax", + "other", + name="servicetype", + create_type=False, +) + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("telegram_id", sa.BigInteger(), nullable=False), + sa.Column("username", sa.String(length=128), nullable=True), + sa.Column("first_name", sa.String(length=128), nullable=True), + sa.Column("last_name", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_telegram_id"), "users", ["telegram_id"], unique=True) + + op.create_table( + "cars", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("owner_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=160), nullable=False), + sa.Column("make", sa.String(length=80), nullable=True), + sa.Column("model", sa.String(length=80), nullable=True), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("plate_number", sa.String(length=32), nullable=True), + sa.Column("vin", sa.String(length=32), nullable=True), + sa.Column("fuel_type", sa.String(length=32), nullable=True), + sa.Column("purchase_date", sa.Date(), nullable=True), + sa.Column("purchase_price", sa.Numeric(12, 2), nullable=True), + sa.Column("current_odometer", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["owner_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_cars_owner_id"), "cars", ["owner_id"], unique=False) + + op.create_table( + "fuel_entries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("car_id", sa.Integer(), nullable=False), + sa.Column("entry_date", sa.Date(), nullable=False), + sa.Column("odometer", sa.Integer(), nullable=False), + sa.Column("liters", sa.Numeric(8, 3), nullable=False), + sa.Column("price_per_liter", sa.Numeric(10, 2), nullable=False), + sa.Column("total_cost", sa.Numeric(12, 2), nullable=False), + sa.Column("station", sa.String(length=160), nullable=True), + sa.Column("fuel_brand", sa.String(length=80), nullable=True), + sa.Column("is_full_tank", sa.Boolean(), nullable=False), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_fuel_entries_car_id"), "fuel_entries", ["car_id"], unique=False) + op.create_index(op.f("ix_fuel_entries_entry_date"), "fuel_entries", ["entry_date"], unique=False) + + op.create_table( + "service_entries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("car_id", sa.Integer(), nullable=False), + sa.Column("entry_date", sa.Date(), nullable=False), + sa.Column("odometer", sa.Integer(), nullable=True), + sa.Column("service_type", service_type, nullable=False), + sa.Column("title", sa.String(length=180), nullable=False), + sa.Column("category", sa.String(length=80), nullable=True), + sa.Column("vendor", sa.String(length=160), nullable=True), + sa.Column("total_cost", sa.Numeric(12, 2), nullable=False), + sa.Column("next_due_date", sa.Date(), nullable=True), + sa.Column("next_due_odometer", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["car_id"], ["cars.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_service_entries_car_id"), "service_entries", ["car_id"], unique=False) + op.create_index(op.f("ix_service_entries_entry_date"), "service_entries", ["entry_date"], unique=False) + op.create_index(op.f("ix_service_entries_service_type"), "service_entries", ["service_type"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_service_entries_service_type"), table_name="service_entries") + op.drop_index(op.f("ix_service_entries_entry_date"), table_name="service_entries") + op.drop_index(op.f("ix_service_entries_car_id"), table_name="service_entries") + op.drop_table("service_entries") + op.drop_index(op.f("ix_fuel_entries_entry_date"), table_name="fuel_entries") + op.drop_index(op.f("ix_fuel_entries_car_id"), table_name="fuel_entries") + op.drop_table("fuel_entries") + op.drop_index(op.f("ix_cars_owner_id"), table_name="cars") + op.drop_table("cars") + op.drop_index(op.f("ix_users_telegram_id"), table_name="users") + op.drop_table("users") + service_type.drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/202605110002_car_catalog.py b/alembic/versions/202605110002_car_catalog.py new file mode 100644 index 0000000..787b355 --- /dev/null +++ b/alembic/versions/202605110002_car_catalog.py @@ -0,0 +1,49 @@ +"""car catalog + +Revision ID: 202605110002 +Revises: 202605110001 +Create Date: 2026-05-11 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605110002" +down_revision: str | None = "202605110001" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "car_makes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.Column("country", sa.String(length=80), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_car_makes_name"), "car_makes", ["name"], unique=True) + + op.create_table( + "car_models", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("make_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["make_id"], ["car_makes.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("make_id", "name", name="uq_car_models_make_name"), + ) + op.create_index(op.f("ix_car_models_make_id"), "car_models", ["make_id"], unique=False) + op.create_index(op.f("ix_car_models_name"), "car_models", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_car_models_name"), table_name="car_models") + op.drop_index(op.f("ix_car_models_make_id"), table_name="car_models") + op.drop_table("car_models") + op.drop_index(op.f("ix_car_makes_name"), table_name="car_makes") + op.drop_table("car_makes") diff --git a/alembic/versions/202605110003_user_preferences.py b/alembic/versions/202605110003_user_preferences.py new file mode 100644 index 0000000..e5bf7c1 --- /dev/null +++ b/alembic/versions/202605110003_user_preferences.py @@ -0,0 +1,26 @@ +"""user preferences + +Revision ID: 202605110003 +Revises: 202605110002 +Create Date: 2026-05-11 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "202605110003" +down_revision: str | None = "202605110002" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column("users", sa.Column("locale", sa.String(length=8), server_default="ru", nullable=False)) + op.add_column("users", sa.Column("currency", sa.String(length=3), server_default="RUB", nullable=False)) + + +def downgrade() -> None: + op.drop_column("users", "currency") + op.drop_column("users", "locale") diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ + diff --git a/app/api/cars.py b/app/api/cars.py new file mode 100644 index 0000000..95b8b8e --- /dev/null +++ b/app/api/cars.py @@ -0,0 +1,57 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_session +from app.models.car import Car +from app.schemas.car import CarCreate, CarRead, CarUpdate + +router = APIRouter(prefix="/cars", tags=["cars"]) + + +@router.post("", response_model=CarRead, status_code=status.HTTP_201_CREATED) +async def create_car(payload: CarCreate, session: AsyncSession = Depends(get_session)) -> Car: + car = Car(**payload.model_dump()) + session.add(car) + await session.commit() + await session.refresh(car) + return car + + +@router.get("", response_model=list[CarRead]) +async def list_cars(owner_id: int, session: AsyncSession = Depends(get_session)) -> list[Car]: + result = await session.execute( + select(Car).where(Car.owner_id == owner_id).order_by(Car.created_at.desc()) + ) + return list(result.scalars()) + + +@router.get("/{car_id}", response_model=CarRead) +async def get_car(car_id: int, session: AsyncSession = Depends(get_session)) -> Car: + car = await session.get(Car, car_id) + if car is None: + raise HTTPException(status_code=404, detail="Car not found") + return car + + +@router.patch("/{car_id}", response_model=CarRead) +async def update_car( + car_id: int, payload: CarUpdate, session: AsyncSession = Depends(get_session) +) -> Car: + car = await session.get(Car, car_id) + if car is None: + raise HTTPException(status_code=404, detail="Car not found") + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(car, field, value) + await session.commit() + await session.refresh(car) + return car + + +@router.delete("/{car_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_car(car_id: int, session: AsyncSession = Depends(get_session)) -> None: + car = await session.get(Car, car_id) + if car is None: + raise HTTPException(status_code=404, detail="Car not found") + await session.delete(car) + await session.commit() diff --git a/app/api/catalog.py b/app/api/catalog.py new file mode 100644 index 0000000..3ca63a3 --- /dev/null +++ b/app/api/catalog.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.session import get_session +from app.models.car import CarMake +from app.schemas.car import CarMakeRead + +router = APIRouter(prefix="/catalog", tags=["catalog"]) + + +@router.get("/makes", response_model=list[CarMakeRead]) +async def list_makes(session: AsyncSession = Depends(get_session)) -> list[CarMake]: + result = await session.execute( + select(CarMake).options(selectinload(CarMake.models)).order_by(CarMake.name) + ) + makes = list(result.scalars()) + for make in makes: + make.models.sort(key=lambda model: model.name) + return makes diff --git a/app/api/entries.py b/app/api/entries.py new file mode 100644 index 0000000..cb9d05b --- /dev/null +++ b/app/api/entries.py @@ -0,0 +1,160 @@ +from io import BytesIO +from datetime import date + +import matplotlib.pyplot as plt +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_session +from app.models.car import Car +from app.models.expense import FuelEntry, ServiceEntry +from app.schemas.expense import ( + FuelEntryCreate, + FuelEntryRead, + OdometerPrediction, + OwnershipStats, + ServiceEntryCreate, + ServiceEntryRead, +) +from app.services.calculations import dataframe_from_query, get_ownership_stats, predict_odometer + +router = APIRouter(tags=["entries"]) + + +async def ensure_car(session: AsyncSession, car_id: int) -> None: + if await session.get(Car, car_id) is None: + raise HTTPException(status_code=404, detail="Car not found") + + +@router.post("/fuel", response_model=FuelEntryRead, status_code=status.HTTP_201_CREATED) +async def create_fuel_entry( + payload: FuelEntryCreate, session: AsyncSession = Depends(get_session) +) -> FuelEntry: + await ensure_car(session, payload.car_id) + entry = FuelEntry(**payload.model_dump()) + session.add(entry) + car = await session.get(Car, payload.car_id) + if car and (car.current_odometer is None or payload.odometer > car.current_odometer): + car.current_odometer = payload.odometer + await session.commit() + await session.refresh(entry) + return entry + + +@router.get("/cars/{car_id}/fuel", response_model=list[FuelEntryRead]) +async def list_fuel_entries( + car_id: int, + date_from: date | None = None, + date_to: date | None = None, + session: AsyncSession = Depends(get_session), +) -> list[FuelEntry]: + stmt = select(FuelEntry).where(FuelEntry.car_id == car_id) + if date_from: + stmt = stmt.where(FuelEntry.entry_date >= date_from) + if date_to: + stmt = stmt.where(FuelEntry.entry_date <= date_to) + result = await session.execute( + stmt.order_by(FuelEntry.entry_date.desc()) + ) + return list(result.scalars()) + + +@router.post("/service", response_model=ServiceEntryRead, status_code=status.HTTP_201_CREATED) +async def create_service_entry( + payload: ServiceEntryCreate, session: AsyncSession = Depends(get_session) +) -> ServiceEntry: + await ensure_car(session, payload.car_id) + entry = ServiceEntry(**payload.model_dump()) + session.add(entry) + car = await session.get(Car, payload.car_id) + if car and payload.odometer and ( + car.current_odometer is None or payload.odometer > car.current_odometer + ): + car.current_odometer = payload.odometer + await session.commit() + await session.refresh(entry) + return entry + + +@router.get("/cars/{car_id}/service", response_model=list[ServiceEntryRead]) +async def list_service_entries( + car_id: int, + date_from: date | None = None, + date_to: date | None = None, + session: AsyncSession = Depends(get_session), +) -> list[ServiceEntry]: + stmt = select(ServiceEntry).where(ServiceEntry.car_id == car_id) + if date_from: + stmt = stmt.where(ServiceEntry.entry_date >= date_from) + if date_to: + stmt = stmt.where(ServiceEntry.entry_date <= date_to) + result = await session.execute( + stmt.order_by(ServiceEntry.entry_date.desc()) + ) + return list(result.scalars()) + + +@router.get("/cars/{car_id}/stats", response_model=OwnershipStats) +async def car_stats( + car_id: int, + date_from: date | None = None, + date_to: date | None = None, + session: AsyncSession = Depends(get_session), +) -> OwnershipStats: + await ensure_car(session, car_id) + today = date.today() + period_from = date_from or today.replace(day=1) + period_to = date_to or today + return await get_ownership_stats(session, car_id, period_from, period_to) + + +@router.get("/cars/{car_id}/analytics", response_model=OdometerPrediction) +async def car_analytics(car_id: int, session: AsyncSession = Depends(get_session)) -> OdometerPrediction: + await ensure_car(session, car_id) + return await predict_odometer(session, car_id) + + +@router.get("/cars/{car_id}/charts/expenses.png") +async def expenses_chart(car_id: int, session: AsyncSession = Depends(get_session)) -> Response: + await ensure_car(session, car_id) + fuel_df = await dataframe_from_query( + session, + select(FuelEntry.entry_date.label("date"), FuelEntry.total_cost.label("cost")).where( + FuelEntry.car_id == car_id + ), + ) + service_df = await dataframe_from_query( + session, + select(ServiceEntry.entry_date.label("date"), ServiceEntry.total_cost.label("cost")).where( + ServiceEntry.car_id == car_id + ), + ) + if fuel_df.empty and service_df.empty: + raise HTTPException(status_code=404, detail="No data for chart") + + frames = [] + if not fuel_df.empty: + fuel_df["type"] = "fuel" + frames.append(fuel_df) + if not service_df.empty: + service_df["type"] = "service" + frames.append(service_df) + + import pandas as pd + + df = pd.concat(frames) + df["date"] = pd.to_datetime(df["date"]) + pivot = df.pivot_table(index="date", columns="type", values="cost", aggfunc="sum").sort_index() + + fig, ax = plt.subplots(figsize=(8, 4.5)) + pivot.plot(kind="bar", stacked=True, ax=ax) + ax.set_title("Car expenses") + ax.set_xlabel("Date") + ax.set_ylabel("Cost") + fig.tight_layout() + + buffer = BytesIO() + fig.savefig(buffer, format="png") + plt.close(fig) + return Response(buffer.getvalue(), media_type="image/png") diff --git a/app/api/ocr.py b/app/api/ocr.py new file mode 100644 index 0000000..37ebac9 --- /dev/null +++ b/app/api/ocr.py @@ -0,0 +1,41 @@ +import re +from decimal import Decimal + +from fastapi import APIRouter, File, UploadFile +from pydantic import BaseModel + +router = APIRouter(prefix="/ocr", tags=["ocr"]) + + +class ReceiptSuggestion(BaseModel): + total_cost: Decimal | None = None + liters: Decimal | None = None + price_per_liter: Decimal | None = None + station: str | None = None + confidence: float + message: str + + +@router.post("/fuel-receipt", response_model=ReceiptSuggestion) +async def scan_fuel_receipt(file: UploadFile = File(...)) -> ReceiptSuggestion: + content = await file.read() + text = content.decode("utf-8", errors="ignore") + numbers = [Decimal(item.replace(",", ".")) for item in re.findall(r"\d+[,.]\d+|\d+", text)] + total = max(numbers) if numbers else None + liters = next((item for item in numbers if Decimal("5") <= item <= Decimal("120")), None) + price = None + if total and liters and liters: + price = (total / liters).quantize(Decimal("0.01")) + + return ReceiptSuggestion( + total_cost=total, + liters=liters, + price_per_liter=price, + station=None, + confidence=0.35 if numbers else 0, + message=( + "OCR-модуль готов к подключению движка распознавания. Сейчас извлекаю числа из текстового слоя/имени файла." + if numbers + else "Не удалось распознать чек. Можно заполнить поля вручную, а OCR-движок подключить отдельным сервисом." + ), + ) diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..a6076e7 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_session +from app.models.user import User +from app.schemas.user import UserPreferencesUpdate, UserRead, UserUpsert + +router = APIRouter(prefix="/users", tags=["users"]) + + +@router.post("", response_model=UserRead) +async def upsert_user(payload: UserUpsert, session: AsyncSession = Depends(get_session)) -> User: + result = await session.execute(select(User).where(User.telegram_id == payload.telegram_id)) + user = result.scalar_one_or_none() + if user is None: + user = User(**payload.model_dump(exclude_none=True)) + session.add(user) + else: + for field, value in payload.model_dump(exclude_none=True).items(): + setattr(user, field, value) + await session.commit() + await session.refresh(user) + return user + + +@router.get("/telegram/{telegram_id}", response_model=UserRead) +async def get_user_by_telegram_id( + telegram_id: int, session: AsyncSession = Depends(get_session) +) -> User: + result = await session.execute(select(User).where(User.telegram_id == telegram_id)) + return result.scalar_one() + + +@router.patch("/{user_id}/preferences", response_model=UserRead) +async def update_preferences( + user_id: int, payload: UserPreferencesUpdate, session: AsyncSession = Depends(get_session) +) -> User: + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + for field, value in payload.model_dump(exclude_none=True).items(): + setattr(user, field, value) + await session.commit() + await session.refresh(user) + return user diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ + diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..29842f9 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,22 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + database_url: str = "postgresql+asyncpg://drivers:drivers@localhost:5432/drivers" + bot_token: str = "" + api_base_url: str = "http://localhost:8000" + webapp_url: str = "http://localhost:8000" + app_host: str = "0.0.0.0" + app_port: int = 8000 + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1 @@ + diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/db/seed.py b/app/db/seed.py new file mode 100644 index 0000000..8b1af5a --- /dev/null +++ b/app/db/seed.py @@ -0,0 +1,214 @@ +import asyncio +from datetime import date +from decimal import Decimal + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.db.session import async_session_factory +from app.models.car import Car, CarMake, CarModel +from app.models.expense import FuelEntry, ServiceEntry, ServiceType +from app.models.user import User +from app.services.catalog_data import CAR_CATALOG + + +MOCK_PLATE_PREFIX = "MOCK" + +MOCK_CARS = [ + ("KIA Sportage", "KIA", "Sportage", 2021, "gasoline", 36200, Decimal("2450000")), + ("Toyota Camry", "Toyota", "Camry", 2020, "gasoline", 58400, Decimal("2850000")), + ("Hyundai Tucson", "Hyundai", "Tucson", 2022, "gasoline", 27100, Decimal("2750000")), + ("Volkswagen Tiguan", "Volkswagen", "Tiguan", 2019, "gasoline", 73400, Decimal("2300000")), + ("BMW X3", "BMW", "X3", 2021, "diesel", 48900, Decimal("4350000")), + ("Mercedes GLC", "Mercedes", "GLC", 2020, "gasoline", 52200, Decimal("4500000")), + ("Nissan X-Trail", "Nissan", "X-Trail", 2018, "gasoline", 91400, Decimal("1850000")), + ("Skoda Octavia", "Skoda", "Octavia", 2021, "gasoline", 46800, Decimal("2050000")), + ("Tesla Model 3", "Tesla", "Model 3", 2022, "electric", 33800, Decimal("3900000")), + ("Haval Jolion", "Haval", "Jolion", 2023, "gasoline", 19600, Decimal("2150000")), +] + + +def month_shift(base: date, months_back: int) -> date: + month_index = base.year * 12 + base.month - 1 - months_back + year = month_index // 12 + month = month_index % 12 + 1 + return date(year, month, min(base.day, 24)) + + +async def seed_catalog(session: AsyncSession) -> None: + result = await session.execute(select(CarMake).options(selectinload(CarMake.models))) + existing = {make.name: make for make in result.scalars()} + + for make_name, model_names in CAR_CATALOG.items(): + make = existing.get(make_name) + if make is None: + make = CarMake(name=make_name) + session.add(make) + await session.flush() + existing_models = set() + else: + existing_models = {model.name for model in make.models} + for model_name in model_names: + if model_name not in existing_models: + session.add(CarModel(make_id=make.id, name=model_name)) + + +async def pick_owner(session: AsyncSession) -> User: + result = await session.execute(select(User).where(User.telegram_id == 1)) + user = result.scalar_one_or_none() + if user: + return user + + user = User(telegram_id=1, username="demo", first_name="Demo") + session.add(user) + await session.flush() + return user + + +async def clear_previous_mock(session: AsyncSession) -> None: + result = await session.execute( + select(Car.id).where(Car.plate_number.like(f"{MOCK_PLATE_PREFIX}-%")) + ) + car_ids = list(result.scalars()) + if not car_ids: + return + await session.execute(delete(ServiceEntry).where(ServiceEntry.car_id.in_(car_ids))) + await session.execute(delete(FuelEntry).where(FuelEntry.car_id.in_(car_ids))) + await session.execute(delete(Car).where(Car.id.in_(car_ids))) + + +async def seed_mock_usage(session: AsyncSession, owner: User) -> None: + await clear_previous_mock(session) + today = date.today() + + for index, (name, make, model, year, fuel_type, start_odo, price) in enumerate(MOCK_CARS, start=1): + car = Car( + owner_id=owner.id, + name=name, + make=make, + model=model, + year=year, + plate_number=f"{MOCK_PLATE_PREFIX}-{index:02d}", + fuel_type=fuel_type, + purchase_date=date(year, min(index, 12), 10), + purchase_price=price, + current_odometer=start_odo, + ) + session.add(car) + await session.flush() + + odometer = start_odo + monthly_km = 820 + index * 95 + consumption = Decimal("7.2") + Decimal(index % 5) * Decimal("0.45") + if fuel_type == "electric": + consumption = Decimal("0") + + for months_back in range(11, -1, -1): + base_day = month_shift(today.replace(day=15), months_back) + odometer += monthly_km + + if fuel_type == "electric": + energy_cost = Decimal(110 + index * 8 + months_back % 3 * 15) + session.add( + ServiceEntry( + car_id=car.id, + entry_date=base_day, + odometer=odometer, + service_type=ServiceType.other, + title="Зарядка и парковка", + category="charging", + vendor="EV network", + total_cost=energy_cost, + ) + ) + else: + liters_per_month = Decimal(monthly_km) * consumption / Decimal(100) + price_per_liter = Decimal("58.50") + Decimal(index % 4) * Decimal("2.10") + for fill in range(2): + fill_date = date(base_day.year, base_day.month, 8 if fill == 0 else 22) + liters = (liters_per_month / 2).quantize(Decimal("0.001")) + session.add( + FuelEntry( + car_id=car.id, + entry_date=fill_date, + odometer=odometer - (monthly_km // 2 if fill == 0 else 0), + liters=liters, + price_per_liter=price_per_liter, + total_cost=(liters * price_per_liter).quantize(Decimal("0.01")), + station=["Shell", "Lukoil", "Gazprom", "Rosneft", "Neste"][index % 5], + is_full_tank=True, + ) + ) + + if months_back in {11, 5}: + session.add( + ServiceEntry( + car_id=car.id, + entry_date=date(base_day.year, base_day.month, 12), + odometer=odometer, + service_type=ServiceType.maintenance, + title="Плановое ТО", + category="regular", + vendor="Service center", + total_cost=Decimal(12500 + index * 950), + next_due_odometer=odometer + 10000, + ) + ) + + if months_back in {8, 2}: + session.add( + ServiceEntry( + car_id=car.id, + entry_date=date(base_day.year, base_day.month, 18), + odometer=odometer, + service_type=ServiceType.tire, + title="Сезонная замена шин", + category="tires", + vendor="Tire shop", + total_cost=Decimal(4200 + index * 180), + ) + ) + + if months_back == 6: + session.add( + ServiceEntry( + car_id=car.id, + entry_date=date(base_day.year, base_day.month, 20), + odometer=odometer, + service_type=ServiceType.insurance, + title="ОСАГО / страховка", + category="insurance", + vendor="Insurance", + total_cost=Decimal(18200 + index * 1150), + ) + ) + + if months_back == index % 10: + session.add( + ServiceEntry( + car_id=car.id, + entry_date=date(base_day.year, base_day.month, 25), + odometer=odometer, + service_type=ServiceType.repair, + title=["Тормозные колодки", "Диагностика подвески", "Замена АКБ", "Ремонт кондиционера"][index % 4], + category="repair", + vendor="Garage", + total_cost=Decimal(7200 + index * 1350), + ) + ) + + car.current_odometer = odometer + + +async def main() -> None: + async with async_session_factory() as session: + await seed_catalog(session) + owner = await pick_owner(session) + await seed_mock_usage(session, owner) + await session.commit() + print(f"Seeded catalog and mock usage for owner_id={owner.id}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..4a9ecb6 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,13 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine(settings.database_url, pool_pre_ping=True) +async_session_factory = async_sessionmaker(engine, expire_on_commit=False) + + +async def get_session() -> AsyncGenerator[AsyncSession, None]: + async with async_session_factory() as session: + yield session diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..bb61a93 --- /dev/null +++ b/app/main.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from app.api import cars, catalog, entries, ocr, users + +app = FastAPI(title="Drivers Bot API", version="0.1.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(users.router, prefix="/api") +app.include_router(catalog.router, prefix="/api") +app.include_router(cars.router, prefix="/api") +app.include_router(entries.router, prefix="/api") +app.include_router(ocr.router, prefix="/api") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ + diff --git a/app/models/car.py b/app/models/car.py new file mode 100644 index 0000000..533c786 --- /dev/null +++ b/app/models/car.py @@ -0,0 +1,55 @@ +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, ForeignKey, Numeric, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class Car(Base): + __tablename__ = "cars" + + id: Mapped[int] = mapped_column(primary_key=True) + owner_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(160)) + make: Mapped[str | None] = mapped_column(String(80)) + model: Mapped[str | None] = mapped_column(String(80)) + year: Mapped[int | None] + plate_number: Mapped[str | None] = mapped_column(String(32)) + vin: Mapped[str | None] = mapped_column(String(32)) + fuel_type: Mapped[str | None] = mapped_column(String(32)) + purchase_date: Mapped[date | None] = mapped_column(Date) + purchase_price: Mapped[Decimal | None] = mapped_column(Numeric(12, 2)) + current_odometer: Mapped[int | None] + 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() + ) + + owner = relationship("User", back_populates="cars") + fuel_entries = relationship("FuelEntry", back_populates="car", cascade="all, delete-orphan") + service_entries = relationship("ServiceEntry", back_populates="car", cascade="all, delete-orphan") + + +class CarMake(Base): + __tablename__ = "car_makes" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(80), unique=True, index=True) + country: Mapped[str | None] = mapped_column(String(80)) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + models = relationship("CarModel", back_populates="make", cascade="all, delete-orphan") + + +class CarModel(Base): + __tablename__ = "car_models" + __table_args__ = (UniqueConstraint("make_id", "name", name="uq_car_models_make_name"),) + + id: Mapped[int] = mapped_column(primary_key=True) + make_id: Mapped[int] = mapped_column(ForeignKey("car_makes.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(100), index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + make = relationship("CarMake", back_populates="models") diff --git a/app/models/expense.py b/app/models/expense.py new file mode 100644 index 0000000..22554f1 --- /dev/null +++ b/app/models/expense.py @@ -0,0 +1,58 @@ +import enum +from datetime import date, datetime +from decimal import Decimal + +from sqlalchemy import Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class ServiceType(str, enum.Enum): + maintenance = "maintenance" + repair = "repair" + fluid = "fluid" + tire = "tire" + inspection = "inspection" + insurance = "insurance" + tax = "tax" + other = "other" + + +class FuelEntry(Base): + __tablename__ = "fuel_entries" + + id: Mapped[int] = mapped_column(primary_key=True) + car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + entry_date: Mapped[date] = mapped_column(Date, index=True) + odometer: Mapped[int] + liters: Mapped[Decimal] = mapped_column(Numeric(8, 3)) + price_per_liter: Mapped[Decimal] = mapped_column(Numeric(10, 2)) + total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + station: Mapped[str | None] = mapped_column(String(160)) + fuel_brand: Mapped[str | None] = mapped_column(String(80)) + is_full_tank: Mapped[bool] = mapped_column(default=True) + notes: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + car = relationship("Car", back_populates="fuel_entries") + + +class ServiceEntry(Base): + __tablename__ = "service_entries" + + id: Mapped[int] = mapped_column(primary_key=True) + car_id: Mapped[int] = mapped_column(ForeignKey("cars.id", ondelete="CASCADE"), index=True) + entry_date: Mapped[date] = mapped_column(Date, index=True) + odometer: Mapped[int | None] + service_type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True) + title: Mapped[str] = mapped_column(String(180)) + category: Mapped[str | None] = mapped_column(String(80)) + vendor: Mapped[str | None] = mapped_column(String(160)) + total_cost: Mapped[Decimal] = mapped_column(Numeric(12, 2)) + next_due_date: Mapped[date | None] = mapped_column(Date) + next_due_odometer: Mapped[int | None] + notes: Mapped[str | None] = mapped_column(Text) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + car = relationship("Car", back_populates="service_entries") diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..bf7c351 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,24 @@ +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + username: Mapped[str | None] = mapped_column(String(128)) + first_name: Mapped[str | None] = mapped_column(String(128)) + last_name: Mapped[str | None] = mapped_column(String(128)) + locale: Mapped[str] = mapped_column(String(8), default="ru", server_default="ru") + currency: Mapped[str] = mapped_column(String(3), default="RUB", server_default="RUB") + 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() + ) + + cars = relationship("Car", back_populates="owner", cascade="all, delete-orphan") diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ + diff --git a/app/schemas/car.py b/app/schemas/car.py new file mode 100644 index 0000000..e580dbf --- /dev/null +++ b/app/schemas/car.py @@ -0,0 +1,58 @@ +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict + + +class CarBase(BaseModel): + name: str + make: str | None = None + model: str | None = None + year: int | None = None + plate_number: str | None = None + vin: str | None = None + fuel_type: str | None = None + purchase_date: date | None = None + purchase_price: Decimal | None = None + current_odometer: int | None = None + + +class CarCreate(CarBase): + owner_id: int + + +class CarUpdate(BaseModel): + name: str | None = None + make: str | None = None + model: str | None = None + year: int | None = None + plate_number: str | None = None + vin: str | None = None + fuel_type: str | None = None + purchase_date: date | None = None + purchase_price: Decimal | None = None + current_odometer: int | None = None + + +class CarRead(CarBase): + id: int + owner_id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class CarModelRead(BaseModel): + id: int + name: str + + model_config = ConfigDict(from_attributes=True) + + +class CarMakeRead(BaseModel): + id: int + name: str + country: str | None = None + models: list[CarModelRead] = [] + + model_config = ConfigDict(from_attributes=True) diff --git a/app/schemas/expense.py b/app/schemas/expense.py new file mode 100644 index 0000000..20e3664 --- /dev/null +++ b/app/schemas/expense.py @@ -0,0 +1,89 @@ +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, ConfigDict, model_validator + +from app.models.expense import ServiceType + + +class FuelEntryBase(BaseModel): + entry_date: date + odometer: int + liters: Decimal + price_per_liter: Decimal + total_cost: Decimal | None = None + station: str | None = None + fuel_brand: str | None = None + is_full_tank: bool = True + notes: str | None = None + + @model_validator(mode="after") + def fill_total_cost(self) -> "FuelEntryBase": + if self.total_cost is None: + self.total_cost = self.liters * self.price_per_liter + return self + + +class FuelEntryCreate(FuelEntryBase): + car_id: int + + +class FuelEntryRead(FuelEntryBase): + id: int + car_id: int + total_cost: Decimal + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ServiceEntryBase(BaseModel): + entry_date: date + odometer: int | None = None + service_type: ServiceType + title: str + category: str | None = None + vendor: str | None = None + total_cost: Decimal + next_due_date: date | None = None + next_due_odometer: int | None = None + notes: str | None = None + + +class ServiceEntryCreate(ServiceEntryBase): + car_id: int + + +class ServiceEntryRead(ServiceEntryBase): + id: int + car_id: int + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class OwnershipStats(BaseModel): + car_id: int + date_from: date + date_to: date + fuel_cost: Decimal + service_cost: Decimal + total_cost: Decimal + liters: Decimal + distance_km: int + avg_consumption_l_per_100km: float | None + cost_per_km: float | None + fuel_entries_count: int + service_entries_count: int + + +class OdometerPrediction(BaseModel): + car_id: int + samples: int + current_odometer: int | None + predicted_today: int | None + predicted_30_days: int | None + avg_km_per_day: float | None + avg_km_per_month: float | None + confidence: float + insight: str diff --git a/app/schemas/user.py b/app/schemas/user.py new file mode 100644 index 0000000..51d764c --- /dev/null +++ b/app/schemas/user.py @@ -0,0 +1,26 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class UserUpsert(BaseModel): + telegram_id: int + username: str | None = None + first_name: str | None = None + last_name: str | None = None + locale: str | None = None + currency: str | None = None + + +class UserPreferencesUpdate(BaseModel): + locale: str | None = None + currency: str | None = None + + +class UserRead(UserUpsert): + id: int + locale: str + currency: str + created_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/app/services/calculations.py b/app/services/calculations.py new file mode 100644 index 0000000..e0eb526 --- /dev/null +++ b/app/services/calculations.py @@ -0,0 +1,134 @@ +from datetime import date +from decimal import Decimal + +import pandas as pd +from sqlalchemy import Select, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.expense import FuelEntry, ServiceEntry +from app.schemas.expense import OdometerPrediction, OwnershipStats + + +async def get_ownership_stats( + session: AsyncSession, car_id: int, date_from: date, date_to: date +) -> OwnershipStats: + fuel_totals = await session.execute( + select( + func.coalesce(func.sum(FuelEntry.total_cost), 0), + func.coalesce(func.sum(FuelEntry.liters), 0), + func.count(FuelEntry.id), + func.min(FuelEntry.odometer), + func.max(FuelEntry.odometer), + ).where( + FuelEntry.car_id == car_id, + FuelEntry.entry_date >= date_from, + FuelEntry.entry_date <= date_to, + ) + ) + fuel_cost, liters, fuel_count, min_odo, max_odo = fuel_totals.one() + + service_totals = await session.execute( + select(func.coalesce(func.sum(ServiceEntry.total_cost), 0), func.count(ServiceEntry.id)).where( + ServiceEntry.car_id == car_id, + ServiceEntry.entry_date >= date_from, + ServiceEntry.entry_date <= date_to, + ) + ) + service_cost, service_count = service_totals.one() + + distance_km = int(max_odo - min_odo) if min_odo is not None and max_odo is not None else 0 + total_cost = Decimal(fuel_cost) + Decimal(service_cost) + avg_consumption = float(Decimal(liters) * Decimal(100) / distance_km) if distance_km else None + cost_per_km = float(total_cost / distance_km) if distance_km else None + + return OwnershipStats( + car_id=car_id, + date_from=date_from, + date_to=date_to, + fuel_cost=fuel_cost, + service_cost=service_cost, + total_cost=total_cost, + liters=liters, + distance_km=distance_km, + avg_consumption_l_per_100km=avg_consumption, + cost_per_km=cost_per_km, + fuel_entries_count=fuel_count, + service_entries_count=service_count, + ) + + +async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFrame: + result = await session.execute(stmt) + rows = result.mappings().all() + return pd.DataFrame(rows) + + +async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction: + fuel = await dataframe_from_query( + session, + select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where( + FuelEntry.car_id == car_id + ), + ) + service = await dataframe_from_query( + session, + select(ServiceEntry.entry_date.label("date"), ServiceEntry.odometer.label("odometer")).where( + ServiceEntry.car_id == car_id, ServiceEntry.odometer.is_not(None) + ), + ) + if fuel.empty and service.empty: + return OdometerPrediction( + car_id=car_id, + samples=0, + current_odometer=None, + predicted_today=None, + predicted_30_days=None, + avg_km_per_day=None, + avg_km_per_month=None, + confidence=0, + insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.", + ) + + df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date") + df["date"] = pd.to_datetime(df["date"]) + df = df.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last") + if len(df) < 2: + current = int(df.iloc[-1]["odometer"]) + return OdometerPrediction( + car_id=car_id, + samples=len(df), + current_odometer=current, + predicted_today=current, + predicted_30_days=None, + avg_km_per_day=None, + avg_km_per_month=None, + confidence=0.2, + insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.", + ) + + first = df.iloc[0] + last = df.iloc[-1] + days = max((last["date"] - first["date"]).days, 1) + distance = max(int(last["odometer"] - first["odometer"]), 0) + km_per_day = distance / days + today = pd.Timestamp.utcnow().tz_localize(None).normalize() + days_since_last = max((today - last["date"]).days, 0) + predicted_today = int(last["odometer"] + km_per_day * days_since_last) + predicted_30 = int(predicted_today + km_per_day * 30) + confidence = min(0.95, 0.35 + len(df) * 0.035 + min(days, 365) / 730) + insight = ( + "Пробег стабилен, прогноз надежный." + if confidence >= 0.75 + else "Прогноз предварительный: точность вырастет после нескольких новых записей." + ) + return OdometerPrediction( + car_id=car_id, + samples=len(df), + current_odometer=int(last["odometer"]), + predicted_today=predicted_today, + predicted_30_days=predicted_30, + avg_km_per_day=round(km_per_day, 1), + avg_km_per_month=round(km_per_day * 30.4, 1), + confidence=round(confidence, 2), + insight=insight, + ) diff --git a/app/services/catalog_data.py b/app/services/catalog_data.py new file mode 100644 index 0000000..bf21da6 --- /dev/null +++ b/app/services/catalog_data.py @@ -0,0 +1,57 @@ +CAR_CATALOG: dict[str, list[str]] = { + "Acura": ["ILX", "Integra", "MDX", "RDX", "TLX", "TSX"], + "Alfa Romeo": ["Giulia", "Giulietta", "Stelvio", "Tonale"], + "Audi": ["A1", "A3", "A4", "A5", "A6", "A7", "A8", "Q2", "Q3", "Q5", "Q7", "Q8", "e-tron", "TT"], + "BMW": ["1 Series", "2 Series", "3 Series", "4 Series", "5 Series", "7 Series", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "i3", "i4", "iX"], + "BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song Plus", "Tang"], + "Cadillac": ["ATS", "CT4", "CT5", "Escalade", "SRX", "XT4", "XT5", "XT6"], + "Changan": ["Alsvin", "CS35 Plus", "CS55 Plus", "CS75 Plus", "UNI-K", "UNI-T", "UNI-V"], + "Chery": ["Arrizo 5", "Arrizo 8", "Tiggo 4", "Tiggo 7", "Tiggo 8", "Tiggo 9"], + "Chevrolet": ["Aveo", "Camaro", "Captiva", "Cobalt", "Cruze", "Equinox", "Lacetti", "Malibu", "Niva", "Spark", "Tahoe", "Trailblazer"], + "Citroen": ["Berlingo", "C3", "C4", "C5 Aircross", "C-Elysee", "Jumpy"], + "Daewoo": ["Gentra", "Lanos", "Matiz", "Nexia"], + "Daihatsu": ["Boon", "Copen", "Mira", "Rocky", "Terios"], + "Dodge": ["Caliber", "Challenger", "Charger", "Durango", "Journey", "Ram"], + "Exeed": ["LX", "RX", "TXL", "VX"], + "FAW": ["Bestune B70", "Bestune T77", "Bestune T99", "Oley"], + "Fiat": ["500", "Albea", "Doblo", "Ducato", "Panda", "Punto", "Tipo"], + "Ford": ["Bronco", "EcoSport", "Edge", "Escape", "Explorer", "F-150", "Fiesta", "Focus", "Fusion", "Kuga", "Mondeo", "Mustang", "Ranger", "Transit"], + "Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"], + "Genesis": ["G70", "G80", "G90", "GV60", "GV70", "GV80"], + "GreatWall": ["Coolbear", "Hover", "Poer", "Safe", "Wingle"], + "Haval": ["Dargo", "F7", "F7x", "H2", "H6", "H9", "Jolion", "M6"], + "Honda": ["Accord", "Civic", "CR-V", "Crosstour", "Fit", "HR-V", "Insight", "Jazz", "Odyssey", "Pilot", "Ridgeline", "Stepwgn", "Vezel"], + "Hongqi": ["E-HS9", "H5", "H7", "H9", "HS5"], + "Hyundai": ["Accent", "Avante", "Creta", "Elantra", "Genesis", "Getz", "Grandeur", "i20", "i30", "ix35", "Kona", "Palisade", "Santa Fe", "Solaris", "Sonata", "Staria", "Tucson", "Venue"], + "Infiniti": ["EX", "FX", "G", "JX", "Q30", "Q50", "Q60", "Q70", "QX50", "QX56", "QX60", "QX70", "QX80"], + "Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"], + "Jeep": ["Cherokee", "Compass", "Grand Cherokee", "Renegade", "Wrangler"], + "Jetour": ["Dashing", "T2", "X70", "X90"], + "KIA": ["Carens", "Carnival", "Ceed", "Cerato", "Forte", "K3", "K5", "Mohave", "Morning", "Niro", "Optima", "Picanto", "Rio", "Seltos", "Sorento", "Soul", "Sportage", "Stinger", "Telluride"], + "LADA": ["2107", "Granta", "Kalina", "Largus", "Niva Legend", "Niva Travel", "Priora", "Vesta", "XRAY"], + "Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RX", "UX"], + "LiXiang": ["L6", "L7", "L8", "L9", "MEGA"], + "Lincoln": ["Aviator", "Continental", "Corsair", "MKC", "MKX", "Navigator"], + "Mazda": ["2", "3", "5", "6", "Atenza", "BT-50", "CX-3", "CX-30", "CX-5", "CX-7", "CX-9", "CX-50", "MX-5"], + "Mercedes": ["A-Class", "B-Class", "C-Class", "CLA", "CLS", "E-Class", "G-Class", "GLA", "GLB", "GLC", "GLE", "GLS", "S-Class", "Sprinter", "V-Class", "Vito"], + "Mini": ["Clubman", "Cooper", "Countryman", "Paceman"], + "Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport", "RVR"], + "Nissan": ["Almera", "Altima", "Juke", "Leaf", "Maxima", "Murano", "Navara", "Note", "Pathfinder", "Patrol", "Qashqai", "Rogue", "Sentra", "Serena", "Teana", "Terrano", "Tiida", "X-Trail"], + "Omoda": ["C5", "E5", "S5"], + "Opel": ["Astra", "Combo", "Corsa", "Crossland", "Insignia", "Meriva", "Mokka", "Vectra", "Zafira"], + "Peugeot": ["2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "Partner", "Traveller"], + "Porsche": ["911", "Boxster", "Cayenne", "Cayman", "Macan", "Panamera", "Taycan"], + "Renault": ["Arkana", "Captur", "Clio", "Duster", "Fluence", "Kangoo", "Kaptur", "Koleos", "Logan", "Megane", "Sandero", "Scenic"], + "Seat": ["Alhambra", "Arona", "Ateca", "Ibiza", "Leon", "Toledo"], + "Skoda": ["Fabia", "Karoq", "Kodiaq", "Octavia", "Rapid", "Roomster", "Scala", "Superb", "Yeti"], + "Smart": ["Forfour", "Fortwo"], + "SsangYong": ["Actyon", "Korando", "Kyron", "Musso", "Rexton", "Tivoli"], + "Subaru": ["BRZ", "Forester", "Impreza", "Legacy", "Levorg", "Outback", "Tribeca", "WRX", "XV"], + "Suzuki": ["Baleno", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Solio", "Swift", "SX4", "Vitara"], + "Tesla": ["Model 3", "Model S", "Model X", "Model Y"], + "Toyota": ["4Runner", "Alphard", "Aqua", "Avalon", "Avensis", "C-HR", "Camry", "Corolla", "Crown", "Fortuner", "Harrier", "Highlander", "Hilux", "Land Cruiser", "Noah", "Prado", "Prius", "RAV4", "Sequoia", "Sienna", "Vellfire", "Venza", "Yaris"], + "Volkswagen": ["Amarok", "Arteon", "Caddy", "Caravelle", "Golf", "Jetta", "Multivan", "Passat", "Polo", "Taos", "Teramont", "Tiguan", "Touareg", "Touran", "Transporter"], + "Volvo": ["C30", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"], + "Zeekr": ["001", "007", "009", "X"], + "УАЗ": ["Буханка", "Патриот", "Пикап", "Хантер"], +} diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1 @@ + diff --git a/bot/api_client.py b/bot/api_client.py new file mode 100644 index 0000000..a03b640 --- /dev/null +++ b/bot/api_client.py @@ -0,0 +1,40 @@ +from typing import Any + +import httpx + +from app.core.config import settings + + +class ApiClient: + def __init__(self) -> None: + self.base_url = settings.api_base_url.rstrip("/") + + async def upsert_user(self, telegram_user: Any) -> dict[str, Any]: + payload = { + "telegram_id": telegram_user.id, + "username": telegram_user.username, + "first_name": telegram_user.first_name, + "last_name": telegram_user.last_name, + } + async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: + response = await client.post("/api/users", json=payload) + response.raise_for_status() + return response.json() + + async def list_cars(self, owner_id: int) -> list[dict[str, Any]]: + async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: + response = await client.get("/api/cars", params={"owner_id": owner_id}) + response.raise_for_status() + return response.json() + + async def create_car(self, owner_id: int, name: str) -> dict[str, Any]: + async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: + response = await client.post("/api/cars", json={"owner_id": owner_id, "name": name}) + response.raise_for_status() + return response.json() + + async def stats(self, car_id: int) -> dict[str, Any]: + async with httpx.AsyncClient(base_url=self.base_url, timeout=10) as client: + response = await client.get(f"/api/cars/{car_id}/stats") + response.raise_for_status() + return response.json() diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..71067ad --- /dev/null +++ b/bot/main.py @@ -0,0 +1,114 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher, F +from aiogram.filters import Command, CommandObject +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + Message, + ReplyKeyboardMarkup, + WebAppInfo, +) + +from app.core.config import settings +from bot.api_client import ApiClient + +logging.basicConfig(level=logging.INFO) + +dp = Dispatcher() +api = ApiClient() + + +def main_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="Открыть гараж", web_app=WebAppInfo(url=settings.webapp_url))], + [KeyboardButton(text="Мои авто"), KeyboardButton(text="Помощь")], + ], + resize_keyboard=True, + ) + + +@dp.message(Command("start")) +async def start(message: Message) -> None: + user = await api.upsert_user(message.from_user) + text = ( + f"Готово, {user.get('first_name') or 'водитель'}.\n\n" + "Здесь можно вести заправки, обслуживание, ремонты и смотреть стоимость владения. " + "Основная работа идет в mini app, а бот остается быстрым входом." + ) + await message.answer(text, reply_markup=main_keyboard()) + + +@dp.message(Command("add_car")) +async def add_car(message: Message, command: CommandObject) -> None: + user = await api.upsert_user(message.from_user) + name = command.args.strip() if command.args else "" + if not name: + await message.answer("Напиши так: /add_car Toyota Camry") + return + car = await api.create_car(user["id"], name) + await message.answer(f"Добавил авто: {car['name']}") + + +@dp.message(Command("cars")) +@dp.message(F.text == "Мои авто") +async def cars(message: Message) -> None: + user = await api.upsert_user(message.from_user) + items = await api.list_cars(user["id"]) + if not items: + await message.answer("Автомобилей пока нет. Добавь через mini app или командой /add_car Название.") + return + + buttons = [ + [InlineKeyboardButton(text=car["name"], callback_data=f"stats:{car['id']}")] for car in items + ] + await message.answer("Твой гараж:", reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons)) + + +@dp.callback_query(F.data.startswith("stats:")) +async def show_stats(callback: CallbackQuery) -> None: + car_id = int(callback.data.split(":", 1)[1]) + stats = await api.stats(car_id) + consumption = stats["avg_consumption_l_per_100km"] + cost_per_km = stats["cost_per_km"] + await callback.message.answer( + "\n".join( + [ + "Статистика авто:", + f"Расходы всего: {stats['total_cost']}", + f"Топливо: {stats['fuel_cost']}", + f"Сервис и ремонты: {stats['service_cost']}", + f"Пробег по записям: {stats['distance_km']} км", + f"Средний расход: {consumption:.2f} л/100 км" if consumption else "Средний расход: нет данных", + f"Стоимость 1 км: {cost_per_km:.2f}" if cost_per_km else "Стоимость 1 км: нет данных", + ] + ) + ) + await callback.answer() + + +@dp.message(F.text == "Помощь") +@dp.message(Command("help")) +async def help_message(message: Message) -> None: + await message.answer( + "Команды:\n" + "/add_car Название - быстро добавить авто\n" + "/cars - список авто и статистика\n\n" + "Заправки, ремонты и обслуживание удобнее вести через кнопку «Открыть гараж».", + reply_markup=main_keyboard(), + ) + + +async def main() -> None: + if not settings.bot_token: + raise RuntimeError("BOT_TOKEN is empty") + bot = Bot(settings.bot_token) + await dp.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..379c958 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-drivers} + POSTGRES_USER: ${POSTGRES_USER:-drivers} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-drivers} + ports: + - "${POSTGRES_PORT:-5433}:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-drivers} -d ${POSTGRES_DB:-drivers}"] + interval: 5s + timeout: 3s + retries: 10 + + api: + build: . + command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000" + env_file: + - path: .env + required: false + environment: + DATABASE_URL: ${DATABASE_URL:-postgresql+asyncpg://drivers:drivers@db:5432/drivers} + BOT_TOKEN: ${BOT_TOKEN:-} + API_BASE_URL: ${API_BASE_URL:-http://api:8000} + WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000} + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + bot: + build: . + command: python -m bot.main + env_file: + - path: .env + required: false + environment: + BOT_TOKEN: ${BOT_TOKEN:-} + API_BASE_URL: ${API_BASE_URL:-http://api:8000} + WEBAPP_URL: ${WEBAPP_URL:-http://localhost:8000} + depends_on: + - api + +volumes: + pgdata: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e4950a2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "drivers-bot" +version = "0.1.0" +description = "Telegram mini app and bot for car ownership expenses" +requires-python = ">=3.11" +dependencies = [ + "aiogram>=3.4,<4.0", + "alembic>=1.13,<2.0", + "asyncpg>=0.29,<1.0", + "fastapi>=0.110,<1.0", + "httpx>=0.27,<1.0", + "matplotlib>=3.8,<4.0", + "pandas>=2.2,<3.0", + "pydantic-settings>=2.2,<3.0", + "python-multipart>=0.0.9,<1.0", + "sqlalchemy[asyncio]>=2.0,<3.0", + "uvicorn[standard]>=0.29,<1.0", +] + +[build-system] +requires = ["setuptools>=69"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*", "bot*"] + +[project.optional-dependencies] +dev = [ + "ruff>=0.4,<1.0", +] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..93bea95 --- /dev/null +++ b/web/index.html @@ -0,0 +1,291 @@ + + + + + + + + + Гараж + + + + + +
+
+
+

Drivers

+

Гараж

+
+
+ + +
+
+ +
+
+ Автомобиль + Не выбран + Добавь авто или выбери из списка +
+
+ Расходы + 0 + топливо, сервис и ремонты +
+
+ Средний расход + - + л/100 км по полным данным +
+
+ +
+ + +
+
+
+ Профиль учета + Старт +
+
+ Добавь авто и первую запись, чтобы видеть точные отчеты +
+ +
+
+

Отчет

+

Стоимость владения

+
+
+ + + +
+
+ +
+ +
+ + + +
+ +
+
+
+

Динамика расходов

+
+ +
+
+
+

Структура

+
+ +
+
+ +
+ + + + + + + +
+ + +
+
+
+ + + + + + + diff --git a/web/manifest.webmanifest b/web/manifest.webmanifest new file mode 100644 index 0000000..ba3fc04 --- /dev/null +++ b/web/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "name": "Гараж: учет авто", + "short_name": "Гараж", + "description": "Учет заправок, сервиса, ремонтов и стоимости владения автомобилем.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#eef3f1", + "theme_color": "#16806a", + "icons": [ + { + "src": "/static/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..490a92e --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,1102 @@ +const tg = window.Telegram?.WebApp; +tg?.ready(); +tg?.expand(); + +const textNodes = new WeakMap(); +const attrOriginals = new WeakMap(); + +const i18n = { + en: { + "Гараж": "Garage", + "Автомобиль": "Vehicle", + "Не выбран": "Not selected", + "Добавь авто или выбери из списка": "Add a vehicle or choose one", + "Расходы": "Expenses", + "топливо, сервис и ремонты": "fuel, service and repairs", + "Средний расход": "Average consumption", + "л/100 км по полным данным": "L/100 km from complete data", + "Автомобили": "Vehicles", + "Профиль учета": "Tracking profile", + "Старт": "Start", + "Добавь авто и первую запись, чтобы видеть точные отчеты": "Add a vehicle and first entry to see accurate reports", + "Отчет": "Report", + "Стоимость владения": "Ownership cost", + "Весь срок": "All time", + "Выбери марку": "Choose make", + "Выбери модель": "Choose model", + "Сначала марка": "Choose make first", + "Топливо": "Fuel", + "Эффективность": "Efficiency", + "Месяц": "Month", + "День": "Day", + "Квартал": "Quarter", + "Год": "Year", + "Свой период": "Custom period", + "Заправка": "Fuel", + "Сервис": "Service", + "Скан чека": "Receipt scan", + "30 сек": "30 sec", + "ТО / ремонт": "Maintenance / repair", + "Динамика расходов": "Expense trend", + "Структура": "Breakdown", + "Дата": "Date", + "Одометр, км": "Odometer, km", + "Литры": "Liters", + "Цена за литр": "Price per liter", + "АЗС": "Fuel station", + "Не выбрано": "Not selected", + "Полный бак": "Full tank", + "Сохранить заправку": "Save fuel entry", + "Тип": "Type", + "Обслуживание": "Maintenance", + "Ремонт": "Repair", + "Жидкости": "Fluids", + "Шины": "Tires", + "Осмотр": "Inspection", + "Страховка": "Insurance", + "Налог": "Tax", + "Другое": "Other", + "Что сделано": "Work done", + "Масло": "Oil", + "Стоимость": "Cost", + "Исполнитель": "Vendor", + "Сохранить запись": "Save entry", + "Меню": "Menu", + "Добавить автомобиль": "Add vehicle", + "Локаль и валюта": "Language and currency", + "Уведомления": "Notifications", + "Сканировать чек": "Scan receipt", + "Настройки": "Settings", + "Язык": "Language", + "Валюта": "Currency", + "Сохранить настройки": "Save settings", + "Напомним о ТО, страховке и регулярном внесении пробега.": "We'll remind you about maintenance, insurance and regular odometer updates.", + "Включить уведомления": "Enable notifications", + "Фото или файл чека": "Receipt photo or file", + "Сфотографировать": "Take photo", + "Выбрать файл": "Choose file", + "Файл не выбран": "No file selected", + "Распознать": "Recognize", + "После распознавания поля заправки заполнятся автоматически.": "After recognition, fuel fields will be filled automatically.", + "Новое авто": "New vehicle", + "Название авто": "Vehicle name", + "Марка": "Make", + "Модель": "Model", + "Добавить авто": "Add vehicle", + "За весь срок": "All time", + "За месяц": "This month", + "За день": "Per day", + "За квартал": "Quarter", + "За год": "Year", + "За период": "Period", + "На 100 км": "Per 100 km", + "На 1 км": "Per 1 km", + "записей": "entries", + "среднее в периоде": "average in period", + "нет данных": "no data", + "Выбери автомобиль": "Choose a vehicle", + "Выбери автомобиль для статистики": "Choose a vehicle for stats", + "Добавь первый автомобиль": "Add your first vehicle", + "Без деталей": "No details", + "Профиль точный": "Accurate profile", + "Хороший учет": "Good tracking", + "Набираем данные": "Collecting data", + "Отчеты уже достаточно надежны для решений по расходам": "Reports are reliable enough for expense decisions", + "Чем регулярнее записи, тем точнее расход, цена километра и напоминания": "More regular entries make consumption, cost per km and reminders more accurate", + "Итого": "Total", + "Стоимость 1 км": "Cost per km", + "Пробег": "Mileage", + "Записей": "Entries", + "Потрачено": "Spent", + "Литров": "Liters", + "Средняя заправка": "Average refill", + "Расход": "Consumption", + "Главная категория": "Top category", + "Макс. категория": "Max category", + "Прогноз сегодня": "Today forecast", + "+30 дней": "+30 days", + "Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.": "Accuracy improves most when odometer is entered at every fuel and service record.", + "Нет записей за выбранный период": "No entries for selected period", + "Добавь заправку или сервисную запись": "Add fuel or service entry", + "Нет расходов": "No expenses", + "топливо": "fuel", + "Уведомления включены": "Notifications enabled", + "Уведомления запрещены в настройках браузера": "Notifications are blocked in browser settings", + "Браузер не поддерживает уведомления": "Browser does not support notifications", + "PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.", + "Напоминания готовы": "Reminders are ready", + "Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.", + }, + ko: { + "Гараж": "차고", + "Автомобиль": "차량", + "Не выбран": "선택 안 됨", + "Добавь авто или выбери из списка": "차량을 추가하거나 목록에서 선택하세요", + "Расходы": "지출", + "топливо, сервис и ремонты": "연료, 정비, 수리", + "Средний расход": "평균 연비", + "л/100 км по полным данным": "완전한 데이터 기준 L/100km", + "Автомобили": "차량", + "Профиль учета": "기록 프로필", + "Старт": "시작", + "Отчет": "리포트", + "Стоимость владения": "소유 비용", + "Весь срок": "전체", + "Выбери марку": "브랜드 선택", + "Выбери модель": "모델 선택", + "Сначала марка": "브랜드를 먼저 선택하세요", + "Топливо": "연료", + "Эффективность": "효율", + "Месяц": "월", + "День": "일", + "Квартал": "분기", + "Год": "년", + "Свой период": "직접 선택", + "Заправка": "주유", + "Сервис": "정비", + "Скан чека": "영수증 스캔", + "30 сек": "30초", + "ТО / ремонт": "정비 / 수리", + "Динамика расходов": "지출 추이", + "Структура": "구성", + "Дата": "날짜", + "Одометр, км": "주행거리, km", + "Литры": "리터", + "Цена за литр": "리터당 가격", + "АЗС": "주유소", + "Не выбрано": "선택 안 됨", + "Полный бак": "가득 주유", + "Сохранить заправку": "주유 저장", + "Тип": "유형", + "Обслуживание": "정비", + "Ремонт": "수리", + "Жидкости": "오일/액체", + "Шины": "타이어", + "Осмотр": "점검", + "Страховка": "보험", + "Налог": "세금", + "Другое": "기타", + "Что сделано": "작업 내용", + "Масло": "오일", + "Стоимость": "비용", + "Исполнитель": "업체", + "Сохранить запись": "기록 저장", + "Меню": "메뉴", + "Добавить автомобиль": "차량 추가", + "Локаль и валюта": "언어와 통화", + "Уведомления": "알림", + "Сканировать чек": "영수증 스캔", + "Настройки": "설정", + "Язык": "언어", + "Валюта": "통화", + "Сохранить настройки": "설정 저장", + "Напомним о ТО, страховке и регулярном внесении пробега.": "정비, 보험, 주행거리 입력을 알려드릴게요.", + "Включить уведомления": "알림 켜기", + "Фото или файл чека": "영수증 사진 또는 파일", + "Сфотографировать": "사진 촬영", + "Выбрать файл": "파일 선택", + "Файл не выбран": "선택된 파일 없음", + "Распознать": "인식", + "После распознавания поля заправки заполнятся автоматически.": "인식 후 주유 입력란이 자동으로 채워집니다.", + "Новое авто": "새 차량", + "Название авто": "차량 이름", + "Марка": "브랜드", + "Модель": "모델", + "Добавить авто": "차량 추가", + "За весь срок": "전체", + "За месяц": "월", + "За день": "일 평균", + "За квартал": "분기", + "За год": "년", + "За период": "기간", + "На 100 км": "100km당", + "На 1 км": "1km당", + "записей": "개 기록", + "среднее в периоде": "기간 평균", + "нет данных": "데이터 없음", + "Выбери автомобиль": "차량을 선택하세요", + "Выбери автомобиль для статистики": "통계를 볼 차량을 선택하세요", + "Добавь первый автомобиль": "첫 차량을 추가하세요", + "Без деталей": "상세 정보 없음", + "Профиль точный": "정확한 프로필", + "Хороший учет": "좋은 기록", + "Набираем данные": "데이터 수집 중", + "Итого": "합계", + "Стоимость 1 км": "1km 비용", + "Пробег": "주행거리", + "Записей": "기록", + "Потрачено": "지출", + "Литров": "리터", + "Средняя заправка": "평균 주유", + "Расход": "연비", + "Главная категория": "주요 카테고리", + "Макс. категория": "최대 카테고리", + "Прогноз сегодня": "오늘 예측", + "+30 дней": "+30일", + "Нет записей за выбранный период": "선택한 기간에 기록이 없습니다", + "Добавь заправку или сервисную запись": "주유 또는 정비 기록을 추가하세요", + "Нет расходов": "지출 없음", + "топливо": "연료", + "Уведомления включены": "알림이 켜졌습니다", + "Уведомления запрещены в настройках браузера": "브라우저 설정에서 알림이 차단되었습니다", + "Браузер не поддерживает уведомления": "브라우저가 알림을 지원하지 않습니다", + "PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.", + "Напоминания готовы": "알림 준비 완료", + "Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.", + }, +}; + +function t(text) { + return i18n[state.user?.locale]?.[text] || text; +} + +function applyTranslations(root = document.body) { + document.documentElement.lang = state.user?.locale || "ru"; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + const parent = node.parentElement; + if (!parent || ["SCRIPT", "STYLE"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT; + return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + }, + }); + while (walker.nextNode()) { + const node = walker.currentNode; + if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim()); + const original = textNodes.get(node); + node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original)); + } + root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => { + ["placeholder", "aria-label", "title"].forEach((attr) => { + const value = element.getAttribute(attr); + if (!value) return; + let originals = attrOriginals.get(element); + if (!originals) { + originals = {}; + attrOriginals.set(element, originals); + } + originals[attr] ||= value; + element.setAttribute(attr, t(originals[attr])); + }); + }); +} + + +const state = { + user: null, + cars: [], + catalog: [], + selectedCarId: null, + latestFuel: [], + latestService: [], + latestStats: null, + allStats: null, + analytics: null, + receiptFile: null, + serviceWorkerRegistration: null, + period: { + preset: "month", + dateFrom: null, + dateTo: null, + }, +}; + +async function initPwa() { + if (!("serviceWorker" in navigator)) return; + try { + state.serviceWorkerRegistration = await navigator.serviceWorker.register("/sw.js"); + } catch (error) { + console.warn("Service worker registration failed", error); + } +} + +function updateNotificationStatus(message) { + const node = document.querySelector("#notificationStatus"); + if (node) node.textContent = t(message); +} + +async function enableNotifications() { + if (!("Notification" in window)) { + updateNotificationStatus("Браузер не поддерживает уведомления"); + return; + } + const permission = await Notification.requestPermission(); + if (permission !== "granted") { + updateNotificationStatus("Уведомления запрещены в настройках браузера"); + return; + } + const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready); + if (registration?.pushManager && window.APP_VAPID_PUBLIC_KEY) { + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY), + }); + localStorage.setItem("driversPushSubscription", JSON.stringify(subscription)); + } + if (registration?.showNotification) { + await registration.showNotification(t("Напоминания готовы"), { + body: t("Мы напомним о ТО, страховке и обновлении пробега."), + icon: "/static/icon.svg", + badge: "/static/icon.svg", + tag: "drivers-bot-ready", + }); + } + updateNotificationStatus("Уведомления включены"); +} + +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0))); +} + +const fallbackUser = { + id: 1, + username: "demo", + first_name: "Demo", + last_name: null, + locale: "ru", + currency: "RUB", +}; + +function today() { + return new Date().toISOString().slice(0, 10); +} + +function formData(form) { + return Object.fromEntries(new FormData(form).entries()); +} + +async function api(path, options = {}) { + const response = await fetch(`/api${path}`, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error(text || response.statusText); + } + if (response.status === 204) return null; + return response.json(); +} + +async function ensureUser() { + const tgUser = tg?.initDataUnsafe?.user || fallbackUser; + state.user = await api("/users", { + method: "POST", + body: JSON.stringify({ + telegram_id: tgUser.id, + username: tgUser.username || null, + first_name: tgUser.first_name || null, + last_name: tgUser.last_name || null, + }), + }); +} + +function money(value) { + const currency = state.user?.currency || "RUB"; + return Number(value || 0).toLocaleString( + { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU", + { style: "currency", currency, maximumFractionDigits: currency === "KRW" ? 0 : 2 }, + ); +} + +function selectedCar() { + return state.cars.find((car) => car.id === state.selectedCarId) || null; +} + +function shiftMonths(base, count) { + const copy = new Date(base); + copy.setMonth(copy.getMonth() + count); + return copy; +} + +function dateValue(date) { + return date.toISOString().slice(0, 10); +} + +function applyPeriodPreset(preset = "month") { + document.querySelector("#periodPreset").value = preset; + const now = new Date(); + const to = dateValue(now); + let fromDate = new Date(now.getFullYear(), now.getMonth(), 1); + if (preset === "all") fromDate = new Date(2000, 0, 1); + if (preset === "day") fromDate = now; + if (preset === "quarter") fromDate = shiftMonths(now, -3); + if (preset === "year") fromDate = shiftMonths(now, -12); + if (preset !== "custom") { + document.querySelector("#periodFrom").value = dateValue(fromDate); + document.querySelector("#periodTo").value = to; + } + state.period = { + preset, + dateFrom: document.querySelector("#periodFrom").value, + dateTo: document.querySelector("#periodTo").value, + }; +} + +function periodQuery() { + const params = new URLSearchParams(); + if (state.period.dateFrom) params.set("date_from", state.period.dateFrom); + if (state.period.dateTo) params.set("date_to", state.period.dateTo); + const query = params.toString(); + return query ? `?${query}` : ""; +} + +function allPeriodQuery() { + return "?date_from=2000-01-01&date_to=2100-01-01"; +} + +async function loadCatalog() { + state.catalog = await api("/catalog/makes"); +} + +function initCarCatalog() { + const makeSelect = document.querySelector("#makeSelect"); + const modelSelect = document.querySelector("#modelSelect"); + const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru")); + makeSelect.innerHTML = `` + makes + .map((make) => ``) + .join(""); + + function syncModels() { + const make = makeSelect.value; + const models = state.catalog.find((item) => item.name === make)?.models || []; + modelSelect.disabled = !models.length; + modelSelect.innerHTML = models.length + ? `` + models.map((model) => ``).join("") + : ``; + } + + makeSelect.addEventListener("change", syncModels); + syncModels(); +} + +function resetCarCatalog() { + document.querySelector("#makeSelect").value = ""; + const modelSelect = document.querySelector("#modelSelect"); + modelSelect.disabled = true; + modelSelect.innerHTML = ``; +} + +function updateHero(stats) { + const car = selectedCar(); + document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран"); + document.querySelector("#selectedCarMeta").textContent = car + ? [car.make, car.model, car.year].filter(Boolean).join(" ") || t("Без деталей") + : t("Добавь авто или выбери из списка"); + document.querySelector("#summaryTotal").textContent = money(stats?.total_cost); + document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km + ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л` + : "-"; +} + +function renderCars() { + const root = document.querySelector("#cars"); + if (!state.cars.length) { + root.innerHTML = `
${t("Добавь первый автомобиль")}
`; + updateHero(null); + return; + } + root.innerHTML = state.cars + .map( + (car) => ` + + `, + ) + .join(""); + root.querySelectorAll("[data-car]").forEach((button) => { + button.addEventListener("click", () => selectCar(Number(button.dataset.car))); + }); +} + +function renderStats(stats) { + const root = document.querySelector("#stats"); + if (!stats) { + root.innerHTML = `
${t("Выбери автомобиль для статистики")}
`; + updateHero(null); + updateScore(); + drawCharts([], [], null); + return; + } + updateHero(stats); + updateScore(); + const all = state.allStats || stats; + const periodDays = Math.max( + 1, + Math.ceil((new Date(stats.date_to) - new Date(stats.date_from)) / 86400000) + 1, + ); + const costPerDay = Number(stats.total_cost || 0) / periodDays; + const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null; + const periodTitles = { + all: t("За весь срок"), + month: t("За месяц"), + day: t("За день"), + quarter: t("За квартал"), + year: t("За год"), + custom: t("За период"), + }; + const periodTitle = periodTitles[state.period.preset] || t("За период"); + root.innerHTML = ` + + + + + + `; + root.querySelectorAll("[data-report]").forEach((button) => { + button.addEventListener("click", () => openReport(button.dataset.report)); + }); +} + +function recordsForPeriod() { + return [ + ...state.latestFuel.map((item) => ({ + date: item.entry_date, + type: "fuel", + title: `Заправка ${Number(item.liters).toFixed(1)} л`, + meta: item.station || `${item.odometer} км`, + cost: item.total_cost, + })), + ...state.latestService.map((item) => ({ + date: item.entry_date, + type: "service", + title: item.title, + meta: item.vendor || serviceLabel(item.service_type), + cost: item.total_cost, + })), + ].sort((a, b) => b.date.localeCompare(a.date)); +} + +function updateScore() { + const car = selectedCar(); + const fuelCount = state.latestFuel.length; + const serviceCount = state.latestService.length; + let score = 0; + if (car) score += 25; + if (fuelCount > 0) score += 25; + if (serviceCount > 0) score += 20; + if (state.latestStats?.distance_km > 0) score += 20; + if (fuelCount + serviceCount >= 6) score += 10; + const title = score >= 90 ? t("Профиль точный") : score >= 60 ? t("Хороший учет") : score >= 30 ? t("Набираем данные") : t("Старт"); + document.querySelector("#scoreTitle").textContent = title; + document.querySelector("#scoreBar").style.width = `${score}%`; + document.querySelector("#scoreHint").textContent = + score >= 90 + ? t("Отчеты уже достаточно надежны для решений по расходам") + : t("Чем регулярнее записи, тем точнее расход, цена километра и напоминания"); +} + +function openReport(type = "summary") { + const stats = state.latestStats; + const sheet = document.querySelector("#reportSheet"); + const title = document.querySelector("#reportTitle"); + const body = document.querySelector("#reportBody"); + const records = recordsForPeriod(); + const titles = { + summary: t("Стоимость владения"), + fuel: t("Топливо"), + service: t("Сервис"), + efficiency: t("Эффективность"), + }; + title.textContent = titles[type] || t("Отчет"); + if (!stats) { + body.innerHTML = `
${t("Выбери автомобиль")}
`; + sheet.classList.remove("hidden"); + return; + } + + const fuelLiters = Number(stats.liters || 0); + const fuelRecords = state.latestFuel; + const serviceRecords = state.latestService; + const avgFill = fuelRecords.length ? fuelLiters / fuelRecords.length : 0; + const serviceByType = serviceRecords.reduce((acc, item) => { + const key = serviceLabel(item.service_type); + acc[key] = (acc[key] || 0) + Number(item.total_cost || 0); + return acc; + }, {}); + const topService = Object.entries(serviceByType).sort((a, b) => b[1] - a[1])[0]; + const analytics = state.analytics; + + const blocks = { + summary: ` +
+ ${reportMetric(t("Итого"), money(stats.total_cost))} + ${reportMetric(t("Стоимость 1 км"), stats.cost_per_km ? money(stats.cost_per_km) : "-")} + ${reportMetric(t("Пробег"), `${stats.distance_km} км`)} + ${reportMetric(t("Записей"), `${stats.fuel_entries_count + stats.service_entries_count}`)} +
+ ${reportRecords(records.slice(0, 8))} + `, + fuel: ` +
+ ${reportMetric(t("Потрачено"), money(stats.fuel_cost))} + ${reportMetric(t("Литров"), fuelLiters.toFixed(1))} + ${reportMetric(t("Средняя заправка"), avgFill ? `${avgFill.toFixed(1)} л` : "-")} + ${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")} +
+ ${reportRecords(records.filter((item) => item.type === "fuel").slice(0, 10))} + `, + service: ` +
+ ${reportMetric(t("Потрачено"), money(stats.service_cost))} + ${reportMetric(t("Записей"), stats.service_entries_count)} + ${reportMetric(t("Главная категория"), topService ? topService[0] : "-")} + ${reportMetric(t("Макс. категория"), topService ? money(topService[1]) : "-")} +
+ ${reportRecords(records.filter((item) => item.type === "service").slice(0, 10))} + `, + efficiency: ` +
+ ${reportMetric("1 км", stats.cost_per_km ? money(stats.cost_per_km) : "-")} + ${reportMetric("100 км", stats.cost_per_km ? money(stats.cost_per_km * 100) : "-")} + ${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")} + ${reportMetric(t("Пробег"), `${stats.distance_km} км`)} + ${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")} + ${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")} +
+
${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}
+ `, + }; + + body.innerHTML = blocks[type] || blocks.summary; + applyTranslations(body); + sheet.classList.remove("hidden"); +} + +function reportMetric(label, value) { + return `
${label}${value}
`; +} + +function reportRecords(records) { + if (!records.length) return `
${t("Нет записей за выбранный период")}
`; + return `
${records + .map( + (item) => ` +
+ ${item.date} +
${item.title}
${item.meta || ""}
+ ${money(item.cost)} +
+ `, + ) + .join("")}
`; +} + +function serviceLabel(value) { + return { + maintenance: t("Обслуживание"), + repair: t("Ремонт"), + fluid: t("Жидкости"), + tire: t("Шины"), + inspection: t("Осмотр"), + insurance: t("Страховка"), + tax: t("Налог"), + other: t("Другое"), + }[value] || value; +} + +function monthlySeries(fuel, service) { + const map = new Map(); + [...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => { + const key = item.entry_date.slice(0, 7); + const current = map.get(key) || { label: key, fuel: 0, service: 0 }; + current[item.type] += Number(item.total_cost || 0); + map.set(key, current); + }); + return [...map.values()].sort((a, b) => a.label.localeCompare(b.label)).slice(-8); +} + +function drawCharts(fuel, service, stats) { + drawExpensesChart(monthlySeries(fuel, service)); + drawSplitChart(Number(stats?.fuel_cost || 0), Number(stats?.service_cost || 0)); +} + +function setupCanvas(canvas) { + const ctx = canvas.getContext("2d"); + const ratio = window.devicePixelRatio || 1; + const rect = canvas.getBoundingClientRect(); + canvas.width = rect.width * ratio; + canvas.height = rect.height * ratio; + ctx.scale(ratio, ratio); + return { ctx, width: rect.width, height: rect.height }; +} + +function drawEmpty(ctx, width, height, text) { + ctx.clearRect(0, 0, width, height); + ctx.fillStyle = "#7c8783"; + ctx.font = "14px system-ui"; + ctx.textAlign = "center"; + ctx.fillText(t(text), width / 2, height / 2); +} + +function drawExpensesChart(series) { + const canvas = document.querySelector("#expensesChart"); + const { ctx, width, height } = setupCanvas(canvas); + if (!series.length) { + drawEmpty(ctx, width, height, "Добавь заправку или сервисную запись"); + return; + } + ctx.clearRect(0, 0, width, height); + const pad = 28; + const chartH = height - pad * 2; + const max = Math.max(...series.map((item) => item.fuel + item.service), 1); + const barGap = 12; + const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length); + + ctx.strokeStyle = "#e1e7e4"; + ctx.lineWidth = 1; + for (let i = 0; i < 4; i += 1) { + const y = pad + (chartH / 3) * i; + ctx.beginPath(); + ctx.moveTo(pad, y); + ctx.lineTo(width - pad, y); + ctx.stroke(); + } + + series.forEach((item, index) => { + const x = pad + index * (barW + barGap); + const total = item.fuel + item.service; + const totalH = (total / max) * chartH; + const fuelH = total ? (item.fuel / total) * totalH : 0; + const serviceH = totalH - fuelH; + const y = height - pad - totalH; + + ctx.fillStyle = "#36a388"; + roundRect(ctx, x, y + serviceH, barW, fuelH, 6); + ctx.fill(); + ctx.fillStyle = "#3f7fba"; + roundRect(ctx, x, y, barW, serviceH, 6); + ctx.fill(); + + ctx.fillStyle = "#7c8783"; + ctx.font = "12px system-ui"; + ctx.textAlign = "center"; + ctx.fillText(item.label.slice(5), x + barW / 2, height - 8); + }); +} + +function drawSplitChart(fuelCost, serviceCost) { + const canvas = document.querySelector("#splitChart"); + const { ctx, width, height } = setupCanvas(canvas); + const total = fuelCost + serviceCost; + if (!total) { + drawEmpty(ctx, width, height, "Нет расходов"); + return; + } + ctx.clearRect(0, 0, width, height); + const cx = width / 2; + const cy = height / 2 - 8; + const radius = Math.min(width, height) * 0.31; + const fuelAngle = (fuelCost / total) * Math.PI * 2; + + ctx.lineWidth = 22; + ctx.lineCap = "round"; + ctx.strokeStyle = "#36a388"; + ctx.beginPath(); + ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle); + ctx.stroke(); + ctx.strokeStyle = "#3f7fba"; + ctx.beginPath(); + ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05); + ctx.stroke(); + + ctx.fillStyle = "#1d2522"; + ctx.font = "700 22px system-ui"; + ctx.textAlign = "center"; + ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5); + ctx.fillStyle = "#7c8783"; + ctx.font = "12px system-ui"; + ctx.fillText(t("топливо"), cx, cy + 25); +} + +function roundRect(ctx, x, y, width, height, radius) { + const r = Math.min(radius, Math.abs(height) / 2, width / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + width, y, x + width, y + height, r); + ctx.arcTo(x + width, y + height, x, y + height, r); + ctx.arcTo(x, y + height, x, y, r); + ctx.arcTo(x, y, x + width, y, r); + ctx.closePath(); +} + +async function loadCars() { + document.body.classList.add("loading"); + state.cars = await api(`/cars?owner_id=${state.user.id}`); + if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id; + if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) { + state.selectedCarId = state.cars[0]?.id || null; + } + renderCars(); + await loadSelectedCar(); + document.body.classList.remove("loading"); +} + +async function selectCar(carId) { + state.selectedCarId = carId; + renderCars(); + await loadSelectedCar(); +} + +async function loadSelectedCar() { + if (!state.selectedCarId) { + state.latestFuel = []; + state.latestService = []; + state.latestStats = null; + state.allStats = null; + state.analytics = null; + renderStats(null); + return; + } + const [stats, allStats, fuel, service, analytics] = await Promise.all([ + api(`/cars/${state.selectedCarId}/stats${periodQuery()}`), + api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`), + api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`), + api(`/cars/${state.selectedCarId}/service${periodQuery()}`), + api(`/cars/${state.selectedCarId}/analytics`), + ]); + state.latestStats = stats; + state.allStats = allStats; + state.latestFuel = fuel; + state.latestService = service; + state.analytics = analytics; + renderStats(stats); + drawCharts(fuel, service, stats); +} + +document.querySelectorAll('input[type="date"]').forEach((input) => { + input.value = today(); +}); + +applyPeriodPreset("month"); + +document.querySelector("#refreshBtn").addEventListener("click", loadCars); + +document.querySelector("#periodPreset").addEventListener("change", async (event) => { + applyPeriodPreset(event.currentTarget.value); + await loadSelectedCar(); +}); + +document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => { + input.addEventListener("change", async () => { + document.querySelector("#periodPreset").value = "custom"; + applyPeriodPreset("custom"); + await loadSelectedCar(); + }); +}); + +document.querySelector("#carForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const data = formData(event.currentTarget); + await api("/cars", { + method: "POST", + body: JSON.stringify({ + owner_id: state.user.id, + name: data.name, + make: data.make || null, + model: data.model || null, + year: data.year ? Number(data.year) : null, + }), + }); + event.currentTarget.reset(); + resetCarCatalog(); + document.querySelector("#userDrawer").classList.add("hidden"); + await loadCars(); +}); + +document.querySelector("#settingsForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const data = formData(event.currentTarget); + state.user = await api(`/users/${state.user.id}/preferences`, { + method: "PATCH", + body: JSON.stringify({ locale: data.locale, currency: data.currency }), + }); + applyTranslations(); + initCarCatalog(); + await loadSelectedCar(); + document.querySelector("#userDrawer").classList.add("hidden"); +}); + +document.querySelector("#fuelForm").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!state.selectedCarId) return; + const data = formData(event.currentTarget); + await api("/fuel", { + method: "POST", + body: JSON.stringify({ + car_id: state.selectedCarId, + entry_date: data.entry_date, + odometer: Number(data.odometer), + liters: Number(data.liters), + price_per_liter: Number(data.price_per_liter), + station: data.station || null, + is_full_tank: Boolean(data.is_full_tank), + }), + }); + event.currentTarget.reset(); + event.currentTarget.entry_date.value = today(); + await loadSelectedCar(); +}); + +document.querySelector("#serviceForm").addEventListener("submit", async (event) => { + event.preventDefault(); + if (!state.selectedCarId) return; + const data = formData(event.currentTarget); + await api("/service", { + method: "POST", + body: JSON.stringify({ + car_id: state.selectedCarId, + entry_date: data.entry_date, + odometer: data.odometer ? Number(data.odometer) : null, + service_type: data.service_type, + title: data.title, + total_cost: Number(data.total_cost), + vendor: data.vendor || null, + }), + }); + event.currentTarget.reset(); + event.currentTarget.entry_date.value = today(); + await loadSelectedCar(); +}); + +function setAction(action) { + document.querySelectorAll(".action-card[data-action]").forEach((button) => { + button.classList.toggle("active", button.dataset.action === action); + }); + document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel"); + document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service"); +} + +document.querySelectorAll("[data-action]").forEach((button) => { + button.addEventListener("click", () => { + if (button.dataset.action === "scan") { + document.querySelector("#userDrawer").classList.remove("hidden"); + document.querySelector("#scanSection").classList.remove("hidden"); + document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" }); + return; + } + setAction(button.dataset.action); + }); +}); + +document.querySelectorAll("[data-report]").forEach((button) => { + button.addEventListener("click", () => { + document.querySelector("#userDrawer").classList.add("hidden"); + openReport(button.dataset.report); + }); +}); + +document.querySelectorAll("[data-service-title]").forEach((button) => { + button.addEventListener("click", () => { + const form = document.querySelector("#serviceForm"); + form.title.value = button.dataset.serviceTitle; + form.service_type.value = button.dataset.serviceType; + }); +}); + +document.querySelector("#menuBtn").addEventListener("click", () => { + document.querySelector("#userDrawer").classList.remove("hidden"); +}); + +document.querySelector("#addCarQuickBtn").addEventListener("click", () => { + document.querySelector("#userDrawer").classList.remove("hidden"); + document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +document.querySelector("#openCarFormBtn").addEventListener("click", () => { + document.querySelector("#carFormSection").classList.remove("hidden"); + document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +document.querySelector("#openSettingsBtn").addEventListener("click", () => { + document.querySelector("#settingsSection").classList.remove("hidden"); + document.querySelector("#localeSelect").value = state.user?.locale || "ru"; + document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; + document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +document.querySelector("#openNotificationsBtn").addEventListener("click", () => { + document.querySelector("#notificationsSection").classList.remove("hidden"); + updateNotificationStatus( + "Notification" in window && Notification.permission === "granted" + ? "Уведомления включены" + : "Напомним о ТО, страховке и регулярном внесении пробега.", + ); + document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications); + +document.querySelector("#openScanBtn").addEventListener("click", () => { + document.querySelector("#scanSection").classList.remove("hidden"); + document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" }); +}); + +function setReceiptFile(file) { + state.receiptFile = file || null; + document.querySelector("#receiptFileName").textContent = file?.name || t("Файл не выбран"); +} + +document.querySelector("#scanCameraBtn").addEventListener("click", () => { + document.querySelector("#receiptCameraInput").click(); +}); + +document.querySelector("#scanFileBtn").addEventListener("click", () => { + document.querySelector("#receiptFileInput").click(); +}); + +document.querySelector("#receiptCameraInput").addEventListener("change", (event) => { + setReceiptFile(event.currentTarget.files[0]); +}); + +document.querySelector("#receiptFileInput").addEventListener("change", (event) => { + setReceiptFile(event.currentTarget.files[0]); +}); + +document.querySelector("#ocrForm").addEventListener("submit", async (event) => { + event.preventDefault(); + const file = state.receiptFile; + if (!file) return; + const payload = new FormData(); + payload.append("file", file); + const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload }); + const result = await response.json(); + document.querySelector("#ocrResult").textContent = result.message; + const form = document.querySelector("#fuelForm"); + if (result.liters) form.liters.value = result.liters; + if (result.price_per_liter) form.price_per_liter.value = result.price_per_liter; + setAction("fuel"); +}); + +document.querySelector("#closeMenuBtn").addEventListener("click", () => { + document.querySelector("#userDrawer").classList.add("hidden"); +}); + +document.querySelector("#closeReportBtn").addEventListener("click", () => { + document.querySelector("#reportSheet").classList.add("hidden"); +}); + +window.addEventListener("resize", () => { + drawCharts(state.latestFuel, state.latestService, state.latestStats); +}); + +initPwa(); + +Promise.all([ensureUser(), loadCatalog()]) + .then(() => { + document.querySelector("#localeSelect").value = state.user?.locale || "ru"; + document.querySelector("#currencySelect").value = state.user?.currency || "RUB"; + applyTranslations(); + initCarCatalog(); + return loadCars(); + }) + .catch((error) => { + document.body.insertAdjacentHTML("afterbegin", `
${error.message}
`); +}); diff --git a/web/static/icon.svg b/web/static/icon.svg new file mode 100644 index 0000000..cbb1224 --- /dev/null +++ b/web/static/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..196b601 --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,878 @@ +:root { + color-scheme: light; + --bg: #eef3f1; + --text: #18211f; + --muted: #73807b; + --line: #d8e1de; + --surface: #ffffff; + --soft: #f7faf8; + --accent: #16806a; + --accent-2: #3f7fba; + --fuel: #36a388; + --service: #3f7fba; + --danger: #b5473e; + --shadow: 0 14px 40px rgba(24, 33, 31, 0.08); +} + +.top-actions { + display: flex; + gap: 8px; +} + +.ghost-btn { + min-height: 34px; + padding: 0 12px; + background: #edf3f0; + color: var(--accent); + box-shadow: none; +} + +.progress-strip { + display: grid; + grid-template-columns: 150px 1fr; + gap: 10px 14px; + align-items: center; + padding: 12px; + margin-bottom: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #f5faf8; +} + +.progress-strip span, +.progress-strip small { + color: var(--muted); +} + +.progress-strip strong { + display: block; + margin-top: 2px; +} + +.progress-strip small { + grid-column: 2; +} + +.progress-track { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #e3ebe7; +} + +.progress-track span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + transition: width 360ms ease; +} + +.stat { + display: grid; + width: 100%; + color: var(--text); + text-align: left; + box-shadow: none; +} + +.quick-actions { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.action-card { + display: grid; + gap: 4px; + min-height: 72px; + color: var(--text); + text-align: left; + background: var(--soft); + border: 1px solid var(--line); + box-shadow: none; +} + +.action-card span { + color: var(--muted); +} + +.action-card.active { + color: #fff; + background: var(--accent); + border-color: var(--accent); +} + +.action-card.active span { + color: rgba(255, 255, 255, 0.78); +} + +.preset-row { + display: flex; + gap: 8px; + align-items: end; + flex-wrap: wrap; +} + +.preset-row button { + min-height: 36px; + padding: 0 12px; + background: #edf3f0; + color: var(--text); + box-shadow: none; +} + +.drawer, +.report-sheet { + position: fixed; + inset: 0; + z-index: 20; + display: grid; + align-items: end; + background: rgba(17, 25, 22, 0.32); + backdrop-filter: blur(5px); +} + +.drawer.hidden, +.report-sheet.hidden { + display: none; +} + +.drawer-panel, +.sheet-panel { + max-height: 88vh; + overflow: auto; + padding: 18px; + border-radius: 16px 16px 0 0; + background: var(--surface); + box-shadow: 0 -18px 50px rgba(24, 33, 31, 0.18); + animation: sheetUp 220ms ease both; +} + +.drawer-panel { + width: min(520px, 100%); + margin-left: auto; +} + +.drawer-section { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--line); +} + +.drawer-form { + grid-template-columns: 1fr; + margin-top: 12px; +} + +.menu-row { + display: block; + width: 100%; + margin-bottom: 8px; + background: var(--soft); + color: var(--text); + text-align: left; + border: 1px solid var(--line); + box-shadow: none; +} + +.wide-btn { + width: 100%; + margin-top: 10px; +} + +.scan-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.scan-actions button { + width: 100%; +} + +.hidden-file { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.file-hint { + min-height: 42px; + display: flex; + align-items: center; + color: var(--muted); + font-size: 13px; + word-break: break-word; +} + +.sheet-panel { + width: min(720px, 100%); + margin: 0 auto; +} + +.report-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.report-metric, +.tip-card { + padding: 12px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--soft); +} + +.report-metric span { + display: block; + color: var(--muted); + font-size: 12px; +} + +.report-metric strong { + display: block; + margin-top: 6px; + font-size: 20px; +} + +.report-records { + max-height: 360px; + overflow: auto; +} + +@keyframes sheetUp { + from { + transform: translateY(24px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@media (max-width: 980px) { + .shell { + padding: 14px 12px 96px; + } + + .hero-grid { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 2px; + } + + .summary-card { + min-width: 78vw; + scroll-snap-align: start; + } + + .cars { + display: flex; + overflow-x: auto; + padding-bottom: 2px; + } + + .car-item { + min-width: 230px; + } + + .quick-actions { + position: sticky; + top: 0; + z-index: 5; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 6px 0; + background: rgba(238, 243, 241, 0.92); + backdrop-filter: blur(8px); + } + + .action-card { + min-height: 58px; + padding: 8px; + } + + .progress-strip, + .report-grid { + grid-template-columns: 1fr; + } + + .progress-strip small { + grid-column: auto; + } +} + +@media (max-width: 980px) { + .shell { + padding: 14px 12px 96px; + } + + .hero-grid { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + padding-bottom: 2px; + } + + .summary-card { + min-width: 78vw; + scroll-snap-align: start; + } + + .cars { + display: flex; + overflow-x: auto; + padding-bottom: 2px; + } + + .car-item { + min-width: 230px; + } + + .quick-actions { + position: sticky; + top: 0; + z-index: 5; + grid-template-columns: repeat(3, minmax(0, 1fr)); + padding: 6px 0; + background: rgba(238, 243, 241, 0.92); + backdrop-filter: blur(8px); + } + + .action-card { + min-height: 58px; + padding: 8px; + } + + .progress-strip, + .report-grid { + grid-template-columns: 1fr; + } + + .progress-strip small { + grid-column: auto; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(238, 243, 241, 0)), + var(--bg); + color: var(--text); + font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + background: var(--accent); + color: #fff; + min-height: 42px; + border-radius: 7px; + padding: 0 16px; + cursor: pointer; + transition: + transform 160ms ease, + box-shadow 160ms ease, + background 160ms ease, + border-color 160ms ease; +} + +button:hover { + transform: translateY(-1px); + box-shadow: 0 10px 24px rgba(22, 128, 106, 0.18); +} + +button:active { + transform: translateY(0) scale(0.99); +} + +button:disabled { + opacity: 0.55; +} + +.shell { + width: min(1200px, 100%); + margin: 0 auto; + padding: 18px; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 0 18px; +} + +.eyebrow { + margin: 0; + color: var(--accent-2); + font-size: 13px; + font-weight: 800; + text-transform: uppercase; +} + +h1, +h2 { + margin: 0; + letter-spacing: 0; +} + +h1 { + font-size: clamp(30px, 4vw, 42px); +} + +h2 { + font-size: 18px; +} + +.icon-btn { + width: 44px; + padding: 0; + background: var(--surface); + color: var(--text); + border: 1px solid var(--line); + box-shadow: none; +} + +.loading .icon-btn { + animation: spin 800ms linear infinite; +} + +.hero-grid { + display: grid; + grid-template-columns: 1.25fr 1fr 1fr; + gap: 12px; + margin-bottom: 16px; +} + +.summary-card, +.band, +.panel, +.workspace, +.chart-card { + background: rgba(255, 255, 255, 0.92); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.summary-card { + display: grid; + gap: 6px; + min-height: 112px; + padding: 16px; + overflow: hidden; + position: relative; + animation: rise 420ms ease both; +} + +.summary-card::after { + content: ""; + position: absolute; + inset: auto -20px -30px auto; + width: 130px; + height: 90px; + background: rgba(54, 163, 136, 0.12); + transform: rotate(-14deg); +} + +.summary-card.accent::after { + background: rgba(54, 163, 136, 0.16); +} + +.summary-card.blue::after { + background: rgba(63, 127, 186, 0.15); +} + +.summary-card span, +.summary-card small, +.stat span, +.stat em { + color: var(--muted); +} + +.summary-card strong { + font-size: clamp(22px, 4vw, 31px); + line-height: 1.05; + z-index: 1; +} + +.summary-card small { + z-index: 1; +} + +.band { + padding: 14px; + margin-bottom: 16px; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.grid-form, +.entry-form { + display: grid; + grid-template-columns: 1.1fr 1fr 1fr 120px auto; + gap: 12px; + align-items: end; +} + +label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 13px; +} + +input, +select { + width: 100%; + min-height: 42px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; + color: var(--text); + padding: 0 11px; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +input:focus, +select:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12); +} + +select:disabled { + background: #f1f5f3; + color: #9aa4a0; +} + +.layout { + display: grid; + grid-template-columns: 310px 1fr; + gap: 16px; + align-items: start; +} + +.panel, +.workspace { + padding: 16px; +} + +.report-bar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 14px; + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.report-bar .eyebrow { + margin-bottom: 2px; +} + +.period-controls { + display: grid; + grid-template-columns: 140px 150px 150px; + gap: 8px; + align-items: center; +} + +.panel { + position: sticky; + top: 12px; +} + +.cars { + display: grid; + gap: 9px; +} + +.car-item { + display: grid; + grid-template-columns: 42px 1fr; + align-items: center; + gap: 10px; + width: 100%; + min-height: 64px; + background: var(--soft); + color: var(--text); + text-align: left; + border: 1px solid transparent; + box-shadow: none; +} + +.car-item small { + display: block; + margin-top: 2px; + color: var(--muted); +} + +.car-item.active { + border-color: rgba(22, 128, 106, 0.48); + background: #e5f4ef; +} + +.car-badge { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 50%; + background: #d7ebe5; + color: #11634f; + font-size: 13px; + font-weight: 800; +} + +.stats { + display: grid; + grid-template-columns: repeat(4, minmax(120px, 1fr)); + gap: 10px; + margin-bottom: 14px; +} + +.stat { + border: 1px solid var(--line); + border-radius: 8px; + padding: 12px; + min-height: 92px; + background: var(--soft); +} + +.stat strong { + display: block; + margin-top: 6px; + font-size: 22px; +} + +.stat em { + display: block; + margin-top: 4px; + font-size: 12px; + font-style: normal; +} + +.pop { + animation: pop 260ms ease both; +} + +.charts { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 12px; + margin-bottom: 14px; +} + +.chart-card { + min-height: 320px; + padding: 14px; + box-shadow: none; +} + +.chart-card canvas { + display: block; + width: 100%; + height: 250px; +} + +.chart-card.compact { + min-width: 0; +} + +.tabs { + display: flex; + gap: 8px; + margin-bottom: 12px; + padding: 4px; + width: fit-content; + background: #edf2ef; + border-radius: 8px; +} + +.tab { + background: transparent; + color: var(--text); + box-shadow: none; +} + +.tab:hover { + box-shadow: none; +} + +.tab.active { + background: var(--accent-2); + color: #fff; +} + +.entry-form { + grid-template-columns: repeat(3, minmax(140px, 1fr)); + border-top: 1px solid var(--line); + padding-top: 14px; + animation: fade 180ms ease both; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + min-height: 42px; +} + +.check input { + width: 18px; + min-height: 18px; + accent-color: var(--accent); +} + +.hidden { + display: none; +} + +.history { + margin-top: 20px; + border-top: 1px solid var(--line); + padding-top: 16px; +} + +.record { + display: grid; + grid-template-columns: 110px 1fr auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--line); + align-items: center; + animation: fade 220ms ease both; +} + +.record small { + color: var(--muted); +} + +.record .fuel { + color: var(--fuel); +} + +.record .service { + color: var(--service); +} + +.empty { + color: var(--muted); + padding: 18px 0; +} + +.error { + margin: 12px auto; + width: min(920px, calc(100% - 24px)); + padding: 12px 14px; + border-radius: 8px; + background: #fff1ef; + border: 1px solid #f0c8c1; + color: var(--danger); +} + +.reveal { + animation: rise 360ms ease both; +} + +@keyframes rise { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes fade { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes pop { + 0% { + opacity: 0; + transform: scale(0.98); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 980px) { + .hero-grid, + .layout, + .charts, + .grid-form, + .entry-form, + .stats, + .period-controls { + grid-template-columns: 1fr; + } + + .report-bar { + align-items: stretch; + flex-direction: column; + } + + .panel { + position: static; + } + + .tabs { + width: 100%; + } + + .tab { + flex: 1; + } + + .record { + grid-template-columns: 1fr; + } +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..5465757 --- /dev/null +++ b/web/sw.js @@ -0,0 +1,55 @@ +const CACHE_NAME = "drivers-garage-v1"; +const APP_SHELL = [ + "/", + "/static/app.js", + "/static/styles.css", + "/static/icon.svg", + "/manifest.webmanifest", +]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)))), + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + if (new URL(request.url).pathname.startsWith("/api/")) return; + event.respondWith( + fetch(request) + .then((response) => { + const copy = response.clone(); + caches.open(CACHE_NAME).then((cache) => cache.put(request, copy)); + return response; + }) + .catch(() => caches.match(request).then((cached) => cached || caches.match("/"))), + ); +}); + +self.addEventListener("push", (event) => { + const data = event.data?.json?.() || {}; + event.waitUntil( + self.registration.showNotification(data.title || "Гараж", { + body: data.body || "Пора обновить данные по автомобилю.", + icon: "/static/icon.svg", + badge: "/static/icon.svg", + tag: data.tag || "drivers-garage-reminder", + data: data.url || "/", + }), + ); +}); + +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(clients.openWindow(event.notification.data || "/")); +});