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.
This commit is contained in:
2026-02-17 12:51:01 +03:00
parent d90d3d1177
commit d60a4fdf3f
23 changed files with 837 additions and 16 deletions

View File

@@ -1 +1,4 @@
BOT_TOKEN=your_bot_token_here BOT_TOKEN=your_bot_token_here
DATABASE_URL=sqlite:///data/duty_teller.db
MINI_APP_BASE_URL=
HTTP_PORT=8080

View File

@@ -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. # 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 FROM python:3.12-slim
WORKDIR /app WORKDIR /app
# Install dependencies # Install gosu (drop privileges in entrypoint)
COPY requirements.txt . RUN apt-get update && apt-get install -y --no-install-recommends gosu \
RUN pip install --no-cache-dir -r requirements.txt && 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 # 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 handlers/ ./handlers/
COPY alembic/ ./alembic/
COPY webapp/ ./webapp/
# Run as non-root # Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
RUN adduser --disabled-password --gecos "" botuser && chown -R botuser:botuser /app RUN adduser --disabled-password --gecos "" botuser \
USER 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"] CMD ["python", "main.py"]

39
alembic.ini Normal file
View File

@@ -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

50
alembic/env.py Normal file
View File

@@ -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()

26
alembic/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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")

1
api/__init__.py Normal file
View File

@@ -0,0 +1 @@
# HTTP API for Mini App

47
api/app.py Normal file
View File

@@ -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")

View File

@@ -1,5 +1,6 @@
"""Load configuration from environment. Fail fast if BOT_TOKEN is missing.""" """Load configuration from environment. Fail fast if BOT_TOKEN is missing."""
import os import os
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -8,3 +9,8 @@ load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN")
if not BOT_TOKEN: if not BOT_TOKEN:
raise SystemExit("BOT_TOKEN is not set. Copy .env.example to .env and set your token from @BotFather.") 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"

BIN
data/duty_teller.db Normal file

Binary file not shown.

11
db/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
"""Database layer: SQLAlchemy models, Pydantic schemas, repository, init."""
from db.models import Base, User, Duty
from db.schemas import UserCreate, UserInDb, DutyCreate, DutyInDb, DutyWithUser
from db.session import get_engine, get_session_factory, get_session
from db.repository import get_or_create_user, get_duties, insert_duty
def init_db(database_url: str) -> None:
"""Create tables from metadata (Alembic migrations handle schema in production)."""
engine = get_engine(database_url)
Base.metadata.create_all(bind=engine)

32
db/models.py Normal file
View File

@@ -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")

61
db/repository.py Normal file
View File

@@ -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

43
db/schemas.py Normal file
View File

@@ -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)

31
db/session.py Normal file
View File

@@ -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)()

View File

@@ -5,8 +5,15 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
env_file: .env env_file: .env
restart: always restart: always
ports:
- "${HTTP_PORT:-8080}:8080"
volumes:
- duty_data:/app/data
logging: logging:
driver: json-file driver: json-file
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
duty_data: {}

11
entrypoint.sh Normal file
View File

@@ -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 "$@"

View File

@@ -1,19 +1,59 @@
"""Command handlers: /start, /help (and placeholder for more).""" """Command handlers: /start, /help; /start registers user and shows Calendar button."""
from telegram import Update import asyncio
import config
from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup
from telegram.ext import CommandHandler, ContextTypes 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: async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message: if not update.message:
await update.message.reply_text("Hello! I'm your bot. Use /help to see available commands.") 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: async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.message: if update.message:
await update.message.reply_text( await update.message.reply_text(
"Available commands:\n" "Доступные команды:\n"
"/start - Start the bot\n" "/start — Начать и открыть календарь\n"
"/help - Show this help" "/help — Показать эту справку"
) )

26
main.py
View File

@@ -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 logging
import threading
import config import config
from telegram.ext import ApplicationBuilder from telegram.ext import ApplicationBuilder
@@ -13,10 +15,30 @@ logging.basicConfig(
logger = logging.getLogger(__name__) 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: def main() -> None:
app = ApplicationBuilder().token(config.BOT_TOKEN).build() app = ApplicationBuilder().token(config.BOT_TOKEN).build()
register_handlers(app) 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"]) app.run_polling(allowed_updates=["message"])

View File

@@ -1,2 +1,7 @@
python-telegram-bot[job-queue]>=22.0,<23.0 python-telegram-bot[job-queue]>=22.0,<23.0
python-dotenv>=1.0,<2.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

145
webapp/app.js Normal file
View File

@@ -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 =
"<span class=\"num\">" + d.getDate() + "</span>" +
(dayDuties.length ? "<span class=\"day-duties\">" + dayDuties.map(function (x) { return x.full_name; }).join(", ") + "</span>" : "");
calendarEl.appendChild(cell);
d.setDate(d.getDate() + 1);
}
monthTitleEl.textContent = MONTHS[month] + " " + year;
}
function renderDutyList(duties) {
if (duties.length === 0) {
dutyListEl.innerHTML = "<p class=\"muted\">В этом месяце дежурств нет.</p>";
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 += "<h2>" + date + "</h2>";
list.forEach(function (d) {
const start = d.start_at.slice(11, 16);
const end = d.end_at.slice(11, 16);
html += "<div class=\"duty-item\"><span class=\"name\">" + escapeHtml(d.full_name) + "</span><div class=\"time\">" + start + " " + end + "</div></div>";
});
});
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();
})();

26
webapp/index.html Normal file
View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>Календарь дежурств</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header class="header">
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц"></button>
<h1 class="title" id="monthTitle"></h1>
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц"></button>
</header>
<div class="weekdays">
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
</div>
<div class="calendar" id="calendar"></div>
<div class="duty-list" id="dutyList"></div>
<div class="loading" id="loading">Загрузка…</div>
<div class="error" id="error" hidden></div>
</div>
<script src="app.js"></script>
</body>
</html>

152
webapp/style.css Normal file
View File

@@ -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;
}