From d60a4fdf3f8a6a937d088f219c8f21c3fc2c9959 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Tue, 17 Feb 2026 12:51:01 +0300 Subject: [PATCH] Enhance Telegram bot with database integration and API features - Added SQLite database support with Alembic for migrations. - Implemented FastAPI for HTTP API to manage duties. - Updated configuration to include database URL and HTTP port. - Created entrypoint script for Docker to handle migrations and permissions. - Expanded command handlers to register users and display duties. - Developed a web application for calendar display of duties. - Included necessary Pydantic schemas and SQLAlchemy models for data handling. - Updated requirements.txt to include new dependencies for FastAPI and SQLAlchemy. --- .env.example | 3 + Dockerfile | 33 +++- alembic.ini | 39 +++++ alembic/env.py | 50 ++++++ alembic/script.py.mako | 26 +++ .../versions/001_initial_users_and_duties.py | 44 +++++ api/__init__.py | 1 + api/app.py | 47 ++++++ config.py | 6 + data/duty_teller.db | Bin 0 -> 24576 bytes db/__init__.py | 11 ++ db/models.py | 32 ++++ db/repository.py | 61 +++++++ db/schemas.py | 43 +++++ db/session.py | 31 ++++ docker-compose.prod.yml | 7 + entrypoint.sh | 11 ++ handlers/commands.py | 54 ++++++- main.py | 26 ++- requirements.txt | 5 + webapp/app.js | 145 +++++++++++++++++ webapp/index.html | 26 +++ webapp/style.css | 152 ++++++++++++++++++ 23 files changed, 837 insertions(+), 16 deletions(-) create mode 100644 alembic.ini create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/001_initial_users_and_duties.py create mode 100644 api/__init__.py create mode 100644 api/app.py create mode 100644 data/duty_teller.db create mode 100644 db/__init__.py create mode 100644 db/models.py create mode 100644 db/repository.py create mode 100644 db/schemas.py create mode 100644 db/session.py create mode 100644 entrypoint.sh create mode 100644 webapp/app.js create mode 100644 webapp/index.html create mode 100644 webapp/style.css diff --git a/.env.example b/.env.example index 69c2963..1c9c133 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,4 @@ BOT_TOKEN=your_bot_token_here +DATABASE_URL=sqlite:///data/duty_teller.db +MINI_APP_BASE_URL= +HTTP_PORT=8080 diff --git a/Dockerfile b/Dockerfile index cc3d7f0..cf49edd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,37 @@ +# Multi-stage: builder installs deps; runtime copies only site-packages and app code. # Single image for both dev and prod; Compose files differentiate behavior. + +# --- Stage 1: builder (dependencies only) --- +FROM python:3.12-slim AS builder +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# --- Stage 2: runtime (minimal final image) --- FROM python:3.12-slim WORKDIR /app -# Install dependencies -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +# Install gosu (drop privileges in entrypoint) +RUN apt-get update && apt-get install -y --no-install-recommends gosu \ + && rm -rf /var/lib/apt/lists/* + +# Copy installed packages and console scripts from builder (no requirements.txt, no pip layer) +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin # Application code -COPY config.py main.py ./ +COPY config.py main.py alembic.ini entrypoint.sh ./ +COPY db/ ./db/ +COPY api/ ./api/ COPY handlers/ ./handlers/ +COPY alembic/ ./alembic/ +COPY webapp/ ./webapp/ -# Run as non-root -RUN adduser --disabled-password --gecos "" botuser && chown -R botuser:botuser /app -USER botuser +# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser +RUN adduser --disabled-password --gecos "" botuser \ + && mkdir -p /app/data && chown -R botuser:botuser /app +# Entrypoint runs as root: fix /app/data ownership (for volume mount), run migrations, then exec as botuser +ENTRYPOINT ["/bin/sh", "./entrypoint.sh"] CMD ["python", "main.py"] diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..e9ef928 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +# Alembic config; url is set in env.py from config +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os + +[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..e9d3d8c --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,50 @@ +"""Alembic env: use config DATABASE_URL and db.models.Base.""" +import os +import sys +from logging.config import fileConfig + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from alembic import context + +load_dotenv() + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from db.models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") +config.set_main_option("sqlalchemy.url", database_url) + +target_metadata = Base.metadata + +connect_args = {"check_same_thread": False} if "sqlite" in database_url else {} + + +def run_migrations_offline() -> None: + context.configure( + url=database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + engine = create_engine(database_url, connect_args=connect_args) + with engine.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_users_and_duties.py b/alembic/versions/001_initial_users_and_duties.py new file mode 100644 index 0000000..39424f6 --- /dev/null +++ b/alembic/versions/001_initial_users_and_duties.py @@ -0,0 +1,44 @@ +"""Initial users and duties tables + +Revision ID: 001 +Revises: +Create Date: 2025-02-17 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("telegram_user_id", sa.BigInteger(), nullable=False), + sa.Column("full_name", sa.Text(), nullable=False), + sa.Column("username", sa.Text(), nullable=True), + sa.Column("first_name", sa.Text(), nullable=True), + sa.Column("last_name", sa.Text(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("telegram_user_id"), + ) + op.create_table( + "duties", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("start_at", sa.Text(), nullable=False), + sa.Column("end_at", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("duties") + op.drop_table("users") diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..0c31800 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1 @@ +# HTTP API for Mini App diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..eb1ad61 --- /dev/null +++ b/api/app.py @@ -0,0 +1,47 @@ +"""FastAPI app: /api/duties and static webapp.""" +from pathlib import Path + +import config +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +from db.session import get_session +from db.repository import get_duties +from db.schemas import DutyWithUser + +app = FastAPI(title="Duty Teller API") +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +@app.get("/api/duties", response_model=list[DutyWithUser]) +def list_duties( + from_date: str = Query(..., description="ISO date YYYY-MM-DD"), + to_date: str = Query(..., description="ISO date YYYY-MM-DD"), +) -> list[DutyWithUser]: + session = get_session(config.DATABASE_URL) + try: + rows = get_duties(session, from_date=from_date, to_date=to_date) + return [ + DutyWithUser( + id=duty.id, + user_id=duty.user_id, + start_at=duty.start_at, + end_at=duty.end_at, + full_name=full_name, + ) + for duty, full_name in rows + ] + finally: + session.close() + + +webapp_path = Path(__file__).resolve().parent.parent / "webapp" +if webapp_path.is_dir(): + app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp") diff --git a/config.py b/config.py index f201ee5..c87eadd 100644 --- a/config.py +++ b/config.py @@ -1,5 +1,6 @@ """Load configuration from environment. Fail fast if BOT_TOKEN is missing.""" import os +from pathlib import Path from dotenv import load_dotenv @@ -8,3 +9,8 @@ load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") if not BOT_TOKEN: raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.") + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db") +MINI_APP_BASE_URL = os.getenv("MINI_APP_BASE_URL", "").rstrip("/") +HTTP_PORT = int(os.getenv("HTTP_PORT", "8080")) +DATA_DIR = Path(__file__).resolve().parent / "data" diff --git a/data/duty_teller.db b/data/duty_teller.db new file mode 100644 index 0000000000000000000000000000000000000000..31cfda176be1b3f1cee59931dd6aa3afda463d5a GIT binary patch literal 24576 zcmeI&&u^1p9LI6!Purx{WCya7&#`1iH+NnlYo67{${eG{od{d05^3uSHOAZAU**5! zKcU`y9@goKn`hI!pA-sTc!19fdU!&Qj|OoTiScYc2{W;4JTOetcqW8l7!@_u)O6j- zYC+eon|ZzPkMD}{`15(I{lloX9vbad`)lj_{cmcKh5!NxAb%k<4fT?`vWPiYNFF@#3Rvnec6+qa1VXq4hMrrqS;ua z(LA_gTbhOQEKuVwU%pX2ZuE+#qg(5*y#7naI}tDBiRi>5Tc6?J(3Ab1t81O**=*s- z1L;Y(E02Y)rTN}X`{G@>ZdsOj`6SPkZ^~ztl~l_vcZSNMBzij!CxPBkr`zxMRQQz$ z<3*AL({K{~olf`qy+ubD$MZD%v$HNGVX<;s_MzK98cNYAUXb0i?MltsvrHqNj-pTL zd7_59LAc0f`FeigX|R(AtzOt>-P+nRzcg}{Fo`Co@mcUuxy7?-VYRd(RoJK>gk`s2 zx|oR9j@Nzec%9v+_6kn-&^`7&N1GLn5PW!lCRU=}IG&2NSE^Y@TX)4O9&D#j&|~>~ z`p4E%liB`hs1FSR1Q0*~0R#|0009ILKmY**)=i*lmNvGxcbX~} None: + """Create tables from metadata (Alembic migrations handle schema in production).""" + engine = get_engine(database_url) + Base.metadata.create_all(bind=engine) diff --git a/db/models.py b/db/models.py new file mode 100644 index 0000000..d6c5374 --- /dev/null +++ b/db/models.py @@ -0,0 +1,32 @@ +"""SQLAlchemy ORM models for users and duties.""" +from sqlalchemy import ForeignKey, Integer, BigInteger, Text +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + """Declarative base for all models.""" + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + telegram_user_id: Mapped[int] = mapped_column(BigInteger, unique=True, nullable=False) + full_name: Mapped[str] = mapped_column(Text, nullable=False) + username: Mapped[str | None] = mapped_column(Text, nullable=True) + first_name: Mapped[str | None] = mapped_column(Text, nullable=True) + last_name: Mapped[str | None] = mapped_column(Text, nullable=True) + + duties: Mapped[list["Duty"]] = relationship("Duty", back_populates="user") + + +class Duty(Base): + __tablename__ = "duties" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False) + start_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 + end_at: Mapped[str] = mapped_column(Text, nullable=False) # ISO 8601 + + user: Mapped["User"] = relationship("User", back_populates="duties") diff --git a/db/repository.py b/db/repository.py new file mode 100644 index 0000000..c603526 --- /dev/null +++ b/db/repository.py @@ -0,0 +1,61 @@ +"""Repository: get_or_create_user, get_duties, insert_duty.""" +from sqlalchemy.orm import Session + +from db.models import User, Duty + + +def get_or_create_user( + session: Session, + telegram_user_id: int, + full_name: str, + username: str | None = None, + first_name: str | None = None, + last_name: str | None = None, +) -> User: + user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first() + if user: + user.full_name = full_name + user.username = username + user.first_name = first_name + user.last_name = last_name + session.commit() + session.refresh(user) + return user + user = User( + telegram_user_id=telegram_user_id, + full_name=full_name, + username=username, + first_name=first_name, + last_name=last_name, + ) + session.add(user) + session.commit() + session.refresh(user) + return user + + +def get_duties( + session: Session, + from_date: str, + to_date: str, +) -> list[tuple[Duty, str]]: + """Return list of (Duty, full_name) overlapping the given date range (ISO date strings).""" + q = ( + session.query(Duty, User.full_name) + .join(User, Duty.user_id == User.id) + .filter(Duty.start_at <= to_date, Duty.end_at >= from_date) + ) + return list(q.all()) + + +def insert_duty( + session: Session, + user_id: int, + start_at: str, + end_at: str, +) -> Duty: + duty = Duty(user_id=user_id, start_at=start_at, end_at=end_at) + session.add(duty) + session.commit() + session.refresh(duty) + return duty diff --git a/db/schemas.py b/db/schemas.py new file mode 100644 index 0000000..3d38be7 --- /dev/null +++ b/db/schemas.py @@ -0,0 +1,43 @@ +"""Pydantic schemas for API and validation.""" +from pydantic import BaseModel, ConfigDict + + +class UserBase(BaseModel): + full_name: str + username: str | None = None + first_name: str | None = None + last_name: str | None = None + + +class UserCreate(UserBase): + telegram_user_id: int + + +class UserInDb(UserBase): + id: int + telegram_user_id: int + + model_config = ConfigDict(from_attributes=True) + + +class DutyBase(BaseModel): + user_id: int + start_at: str # ISO 8601 + end_at: str # ISO 8601 + + +class DutyCreate(DutyBase): + pass + + +class DutyInDb(DutyBase): + id: int + + model_config = ConfigDict(from_attributes=True) + + +class DutyWithUser(DutyInDb): + """Duty with full_name for calendar display.""" + full_name: str + + model_config = ConfigDict(from_attributes=True) diff --git a/db/session.py b/db/session.py new file mode 100644 index 0000000..052a52d --- /dev/null +++ b/db/session.py @@ -0,0 +1,31 @@ +"""SQLAlchemy engine and session factory.""" +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from db.models import Base + +_engine = None +_SessionLocal = None + + +def get_engine(database_url: str): + global _engine + if _engine is None: + _engine = create_engine( + database_url, + connect_args={"check_same_thread": False} if "sqlite" in database_url else {}, + echo=False, + ) + return _engine + + +def get_session_factory(database_url: str) -> sessionmaker[Session]: + global _SessionLocal + if _SessionLocal is None: + engine = get_engine(database_url) + _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + return _SessionLocal + + +def get_session(database_url: str) -> Session: + return get_session_factory(database_url)() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c49938b..4fc86b8 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -5,8 +5,15 @@ services: dockerfile: Dockerfile env_file: .env restart: always + ports: + - "${HTTP_PORT:-8080}:8080" + volumes: + - duty_data:/app/data logging: driver: json-file options: max-size: "10m" max-file: "3" + +volumes: + duty_data: {} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..828dcd2 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -e +# Ensure data dir exists and is writable by botuser (volume may be root-owned) +mkdir -p /app/data +chown botuser:botuser /app/data +# Apply Alembic migrations (runs as root, creates DB in /app/data) +alembic upgrade head +# Ensure new DB file is owned by botuser so the app can write +chown -R botuser:botuser /app/data +# Run the app as botuser +exec gosu botuser "$@" diff --git a/handlers/commands.py b/handlers/commands.py index ad30c0f..d42ce8a 100644 --- a/handlers/commands.py +++ b/handlers/commands.py @@ -1,19 +1,59 @@ -"""Command handlers: /start, /help (and placeholder for more).""" -from telegram import Update +"""Command handlers: /start, /help; /start registers user and shows Calendar button.""" +import asyncio + +import config +from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup from telegram.ext import CommandHandler, ContextTypes +from db.session import get_session +from db.repository import get_or_create_user + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message: - await update.message.reply_text("Hello! I'm your bot. Use /help to see available commands.") + if not update.message: + return + user = update.effective_user + if not user: + return + full_name = " ".join(filter(None, [user.first_name or "", user.last_name or ""])).strip() or "User" + telegram_user_id = user.id + username = user.username + first_name = user.first_name + last_name = user.last_name + + def do_get_or_create() -> None: + session = get_session(config.DATABASE_URL) + try: + get_or_create_user( + session, + telegram_user_id=telegram_user_id, + full_name=full_name, + username=username, + first_name=first_name, + last_name=last_name, + ) + finally: + session.close() + + await asyncio.get_event_loop().run_in_executor(None, do_get_or_create) + + text = "Привет! Я бот календаря дежурств. Используй /help для списка команд." + if config.MINI_APP_BASE_URL: + keyboard = ReplyKeyboardMarkup( + [[KeyboardButton("📅 Календарь", web_app=WebAppInfo(url=config.MINI_APP_BASE_URL + "/app/"))]], + resize_keyboard=True, + ) + await update.message.reply_text(text, reply_markup=keyboard) + else: + await update.message.reply_text(text) async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: await update.message.reply_text( - "Available commands:\n" - "/start - Start the bot\n" - "/help - Show this help" + "Доступные команды:\n" + "/start — Начать и открыть календарь\n" + "/help — Показать эту справку" ) diff --git a/main.py b/main.py index eba5bd0..9b48401 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,7 @@ -"""Single entry point: build Application, register handlers, run polling.""" +"""Single entry point: build Application, run HTTP server + polling. Migrations run in Docker entrypoint.""" +import asyncio import logging +import threading import config from telegram.ext import ApplicationBuilder @@ -13,10 +15,30 @@ logging.basicConfig( logger = logging.getLogger(__name__) +def _run_uvicorn(web_app, port: int) -> None: + """Run uvicorn in a dedicated thread with its own event loop.""" + import uvicorn + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + server = uvicorn.Server( + uvicorn.Config(web_app, host="0.0.0.0", port=port, log_level="info"), + ) + loop.run_until_complete(server.serve()) + + def main() -> None: app = ApplicationBuilder().token(config.BOT_TOKEN).build() register_handlers(app) - logger.info("Bot starting (polling)...") + + from api.app import app as web_app + t = threading.Thread( + target=_run_uvicorn, + args=(web_app, config.HTTP_PORT), + daemon=True, + ) + t.start() + + logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT) app.run_polling(allowed_updates=["message"]) diff --git a/requirements.txt b/requirements.txt index f603749..5d63ab2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,7 @@ python-telegram-bot[job-queue]>=22.0,<23.0 python-dotenv>=1.0,<2.0 +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.32,<1.0 +sqlalchemy>=2.0,<3.0 +alembic>=1.14,<2.0 +pydantic>=2.0,<3.0 diff --git a/webapp/app.js b/webapp/app.js new file mode 100644 index 0000000..12d77f3 --- /dev/null +++ b/webapp/app.js @@ -0,0 +1,145 @@ +(function () { + const MONTHS = [ + "Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", + "Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь" + ]; + + let current = new Date(); + const calendarEl = document.getElementById("calendar"); + const monthTitleEl = document.getElementById("monthTitle"); + const dutyListEl = document.getElementById("dutyList"); + const loadingEl = document.getElementById("loading"); + const errorEl = document.getElementById("error"); + + function isoDate(d) { + return d.toISOString().slice(0, 10); + } + + function firstDayOfMonth(d) { + return new Date(d.getFullYear(), d.getMonth(), 1); + } + + function lastDayOfMonth(d) { + return new Date(d.getFullYear(), d.getMonth() + 1, 0); + } + + function getMonday(d) { + const day = d.getDay(); + const diff = d.getDate() - day + (day === 0 ? -6 : 1); + return new Date(d.getFullYear(), d.getMonth(), diff); + } + + async function fetchDuties(from, to) { + const base = window.location.origin; + const url = base + "/api/duties?from=" + encodeURIComponent(from) + "&to=" + encodeURIComponent(to); + const res = await fetch(url); + if (!res.ok) throw new Error("Ошибка загрузки"); + return res.json(); + } + + function renderCalendar(year, month, dutiesByDate) { + const first = firstDayOfMonth(new Date(year, month, 1)); + const last = lastDayOfMonth(new Date(year, month, 1)); + const start = getMonday(first); + const today = isoDate(new Date()); + + calendarEl.innerHTML = ""; + let d = new Date(start); + const cells = 42; + for (let i = 0; i < cells; i++) { + const key = isoDate(d); + const isOther = d.getMonth() !== month; + const dayDuties = dutiesByDate[key] || []; + const isToday = key === today; + + const cell = document.createElement("div"); + cell.className = "day" + (isOther ? " other-month" : "") + (isToday ? " today" : "") + (dayDuties.length ? " has-duty" : ""); + cell.innerHTML = + "" + d.getDate() + "" + + (dayDuties.length ? "" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "" : ""); + calendarEl.appendChild(cell); + d.setDate(d.getDate() + 1); + } + + monthTitleEl.textContent = MONTHS[month] + " " + year; + } + + function renderDutyList(duties) { + if (duties.length === 0) { + dutyListEl.innerHTML = "

В этом месяце дежурств нет.

"; + return; + } + const grouped = {}; + duties.forEach(function (d) { + const date = d.start_at.slice(0, 10); + if (!grouped[date]) grouped[date] = []; + grouped[date].push(d); + }); + const dates = Object.keys(grouped).sort(); + let html = ""; + dates.forEach(function (date) { + const list = grouped[date]; + html += "

" + date + "

"; + list.forEach(function (d) { + const start = d.start_at.slice(11, 16); + const end = d.end_at.slice(11, 16); + html += "
" + escapeHtml(d.full_name) + "
" + start + " – " + end + "
"; + }); + }); + dutyListEl.innerHTML = html; + } + + function escapeHtml(s) { + const div = document.createElement("div"); + div.textContent = s; + return div.innerHTML; + } + + function dutiesByDate(duties) { + const byDate = {}; + duties.forEach(function (d) { + const start = new Date(d.start_at); + const end = new Date(d.end_at); + for (let t = new Date(start); t <= end; t.setDate(t.getDate() + 1)) { + const key = isoDate(t); + if (!byDate[key]) byDate[key] = []; + byDate[key].push(d); + } + }); + return byDate; + } + + function showError(msg) { + errorEl.textContent = msg; + errorEl.hidden = false; + loadingEl.classList.add("hidden"); + } + + async function loadMonth() { + loadingEl.classList.remove("hidden"); + errorEl.hidden = true; + const from = isoDate(firstDayOfMonth(current)); + const to = isoDate(lastDayOfMonth(current)); + try { + const duties = await fetchDuties(from, to); + const byDate = dutiesByDate(duties); + renderCalendar(current.getFullYear(), current.getMonth(), byDate); + renderDutyList(duties); + } catch (e) { + showError(e.message || "Не удалось загрузить данные."); + return; + } + loadingEl.classList.add("hidden"); + } + + document.getElementById("prevMonth").addEventListener("click", function () { + current.setMonth(current.getMonth() - 1); + loadMonth(); + }); + document.getElementById("nextMonth").addEventListener("click", function () { + current.setMonth(current.getMonth() + 1); + loadMonth(); + }); + + loadMonth(); +})(); diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..e2cf88a --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,26 @@ + + + + + + Календарь дежурств + + + +
+
+ +

+ +
+
+ ПнВтСрЧтПтСбВс +
+
+
+
Загрузка…
+ +
+ + + diff --git a/webapp/style.css b/webapp/style.css new file mode 100644 index 0000000..7b2badb --- /dev/null +++ b/webapp/style.css @@ -0,0 +1,152 @@ +:root { + --bg: #1a1b26; + --surface: #24283b; + --text: #c0caf5; + --muted: #565f89; + --accent: #7aa2f7; + --duty: #9ece6a; + --today: #bb9af7; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + -webkit-tap-highlight-color: transparent; +} + +.container { + max-width: 420px; + margin: 0 auto; + padding: 12px; + padding-bottom: env(safe-area-inset-bottom, 12px); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.nav { + width: 40px; + height: 40px; + border: none; + border-radius: 10px; + background: var(--surface); + color: var(--accent); + font-size: 24px; + line-height: 1; + cursor: pointer; +} + +.nav:active { + opacity: 0.8; +} + +.title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + margin-bottom: 6px; + font-size: 0.75rem; + color: var(--muted); + text-align: center; +} + +.calendar { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 16px; +} + +.day { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 4px; + border-radius: 8px; + font-size: 0.85rem; + background: var(--surface); +} + +.day.other-month { + opacity: 0.4; +} + +.day.today { + background: var(--today); + color: var(--bg); +} + +.day.has-duty .num { + font-weight: 700; +} + +.day-duties { + font-size: 0.6rem; + color: var(--duty); + margin-top: 2px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.duty-list { + font-size: 0.9rem; +} + +.duty-list h2 { + font-size: 0.85rem; + color: var(--muted); + margin: 0 0 8px 0; +} + +.duty-item { + padding: 8px 10px; + margin-bottom: 6px; + border-radius: 8px; + background: var(--surface); + border-left: 3px solid var(--duty); +} + +.duty-item .name { + font-weight: 600; +} + +.duty-item .time { + font-size: 0.8rem; + color: var(--muted); +} + +.loading, .error { + text-align: center; + padding: 12px; + color: var(--muted); +} + +.error { + color: #f7768e; +} + +.error[hidden], .loading.hidden { + display: none !important; +}