8 Commits

Author SHA1 Message Date
0d28123d0b feat: enhance current duty display with remaining time and improved contact links
All checks were successful
CI / lint-and-test (push) Successful in 35s
Docker Build and Release / build-and-push (push) Successful in 51s
Docker Build and Release / release (push) Successful in 8s
- Added a new message key for displaying remaining time until the end of the shift in both English and Russian.
- Updated the current duty card to show remaining time with a formatted string.
- Enhanced the contact links to support block layout with icons for phone and Telegram, improving visual presentation.
- Implemented a new utility function to calculate remaining time until the end of the shift.
- Added unit tests for the new functionality, ensuring accurate time calculations and proper rendering of contact links.
2026-03-02 19:04:30 +03:00
2e78b3c1e6 style: update calendar CSS for improved layout and visual consistency
All checks were successful
CI / lint-and-test (push) Successful in 33s
- Adjusted the layout of the `.day-indicator` to use a fixed width for better alignment.
- Modified the `.day-indicator-dot` styles to enhance flexibility and visual appearance, including changes to height and border-radius for better presentation.
- Ensured that the first and last dots have distinct border-radius styles when not the only child, improving the overall aesthetics of the calendar display.
- No functional changes were made; the focus was on enhancing the visual presentation of the calendar component.
2026-03-02 18:03:18 +03:00
bdead6eef7 refactor: improve code formatting and readability in configuration and run files
All checks were successful
CI / lint-and-test (push) Successful in 38s
- Simplified the assignment of `bot_username` in `config.py` for better clarity.
- Removed redundant import statement in `run.py` to streamline the code.
- Enhanced formatting in `group_duty_pin.py` and test files for improved readability and consistency.
- No functional changes were made; the focus was on code style and organization.
2026-03-02 17:22:55 +03:00
2fb553567f feat: enhance CI workflow and update webapp styles
Some checks failed
CI / lint-and-test (push) Failing after 45s
- Added Node.js setup and webapp testing steps to the CI workflow for improved integration.
- Updated HTML to link multiple CSS files for better modularity and organization of styles.
- Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability.
- Implemented new styles for improved presentation of duty information and user interactions.
- Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
2026-03-02 17:20:33 +03:00
e3240d0981 feat: enhance duty information handling with contact details and current duty view
- Added `bot_username` to settings for dynamic retrieval of the bot's username.
- Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats.
- Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information.
- Enhanced API responses to include contact details for users, ensuring better communication.
- Introduced a new current duty view in the web app, displaying active duty information along with contact options.
- Updated CSS styles for better presentation of contact information in duty cards.
- Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
2026-03-02 16:09:08 +03:00
f8aceabab5 feat: add trusted groups functionality for duty information
- Introduced a new `trusted_groups` table to store groups authorized to receive duty information.
- Implemented functions to add, remove, and check trusted groups in the database.
- Enhanced command handlers to manage trusted groups, including `/trust_group` and `/untrust_group` commands for admin users.
- Updated internationalization messages to support new commands and group status notifications.
- Added unit tests for trusted groups repository functions to ensure correct behavior and data integrity.
2026-03-02 13:07:13 +03:00
322b553b80 feat: enhance group duty pin functionality to delete old messages
- Updated the `_refresh_pin_for_chat` function to delete the old pinned message after sending a new one, ensuring a cleaner chat experience.
- Modified related unit tests to verify the new deletion behavior, including handling exceptions when the old message cannot be deleted.
- Improved documentation in test cases to reflect the updated functionality and error handling.
2026-03-02 12:51:28 +03:00
a4d8d085c6 feat: update language support and enhance API functionality
- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
2026-03-02 12:40:49 +03:00
55 changed files with 5008 additions and 1284 deletions

View File

@@ -39,3 +39,14 @@ jobs:
- name: Security check with Bandit
run: |
bandit -r duty_teller -ll
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: "20"
- name: Webapp tests
run: |
cd webapp
npm ci
npm run test

View File

@@ -0,0 +1,32 @@
"""Add trusted_groups table.
Revision ID: 009
Revises: 008
Create Date: 2025-03-02
Table for groups authorized to receive duty information.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"trusted_groups",
sa.Column("chat_id", sa.BigInteger(), nullable=False),
sa.Column("added_by_user_id", sa.BigInteger(), nullable=True),
sa.Column("added_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("chat_id"),
)
def downgrade() -> None:
op.drop_table("trusted_groups")

View File

@@ -5,6 +5,8 @@ import re
from datetime import date, timedelta
import duty_teller.config as config
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response
@@ -56,6 +58,22 @@ app.add_middleware(
)
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
async def dispatch(self, request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith("/app/") and (
path.endswith(".js") or path.endswith(".html")
):
response.headers["Cache-Control"] = "no-store"
return response
app.add_middleware(NoCacheStaticMiddleware)
@app.get(
"/api/duties",
response_model=list[DutyWithUser],
@@ -126,7 +144,9 @@ def get_team_calendar_ical(
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
(d, name)
for d, name, *_ in all_duties
if (d.event_type or "duty") == "duty"
]
ics_bytes = build_team_ics(duties_duty_only)
ics_calendar_cache.set(cache_key, ics_bytes)

View File

@@ -190,7 +190,7 @@ def fetch_duties_response(
to_date: End date YYYY-MM-DD.
Returns:
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
"""
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
@@ -203,6 +203,8 @@ def fetch_duties_response(
event_type=(
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
),
phone=phone,
username=username,
)
for duty, full_name in rows
for duty, full_name, phone, username in rows
]

View File

@@ -52,6 +52,7 @@ class Settings:
bot_token: str
database_url: str
bot_username: str
mini_app_base_url: str
http_host: str
http_port: int
@@ -93,9 +94,11 @@ class Settings:
)
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
http_host = raw_host if raw_host else "127.0.0.1"
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
return cls(
bot_token=bot_token,
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
bot_username=bot_username,
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
http_host=http_host,
http_port=int(os.getenv("HTTP_PORT", "8080")),
@@ -123,6 +126,7 @@ _settings = Settings.from_env()
BOT_TOKEN = _settings.bot_token
DATABASE_URL = _settings.database_url
BOT_USERNAME = _settings.bot_username
MINI_APP_BASE_URL = _settings.mini_app_base_url
HTTP_HOST = _settings.http_host
HTTP_PORT = _settings.http_port

View File

@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
class TrustedGroup(Base):
"""Groups authorized to receive duty information."""
__tablename__ = "trusted_groups"
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
added_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
added_at: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -11,6 +11,7 @@ from duty_teller.db.models import (
User,
Duty,
GroupDutyPin,
TrustedGroup,
CalendarSubscriptionToken,
Role,
)
@@ -316,8 +317,8 @@ def get_duties(
session: Session,
from_date: str,
to_date: str,
) -> list[tuple[Duty, str]]:
"""Return duties overlapping the given date range with user full_name.
) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties overlapping the given date range with user full_name, phone, username.
Args:
session: DB session.
@@ -325,11 +326,11 @@ def get_duties(
to_date: End date YYYY-MM-DD.
Returns:
List of (Duty, full_name) tuples.
List of (Duty, full_name, phone, username) tuples.
"""
to_date_next = to_date_exclusive_iso(to_date)
q = (
session.query(Duty, User.full_name)
session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id)
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
)
@@ -342,7 +343,7 @@ def get_duties_for_user(
from_date: str,
to_date: str,
event_types: list[str] | None = None,
) -> list[tuple[Duty, str]]:
) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties for one user overlapping the date range.
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
@@ -356,7 +357,7 @@ def get_duties_for_user(
event_types: If not None, only return duties whose event_type is in this list.
Returns:
List of (Duty, full_name) tuples.
List of (Duty, full_name, phone, username) tuples.
"""
to_date_next = to_date_exclusive_iso(to_date)
filters = [
@@ -367,7 +368,7 @@ def get_duties_for_user(
if event_types is not None:
filters.append(Duty.event_type.in_(event_types))
q = (
session.query(Duty, User.full_name)
session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id)
.filter(*filters)
)
@@ -593,6 +594,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
return [r[0] for r in rows]
def is_trusted_group(session: Session, chat_id: int) -> bool:
"""Check if the chat is in the trusted groups list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return (
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
is not None
)
def add_trusted_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> TrustedGroup:
"""Add a group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
Returns:
Created TrustedGroup instance.
"""
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
record = TrustedGroup(
chat_id=chat_id,
added_by_user_id=added_by_user_id,
added_at=now_iso,
)
session.add(record)
session.commit()
session.refresh(record)
return record
def remove_trusted_group(session: Session, chat_id: int) -> None:
"""Remove a group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
session.commit()
def get_all_trusted_group_ids(session: Session) -> list[int]:
"""Return all chat_ids that are trusted.
Args:
session: DB session.
Returns:
List of trusted chat ids.
"""
rows = session.query(TrustedGroup.chat_id).all()
return [r[0] for r in rows]
def set_user_phone(
session: Session, telegram_user_id: int, phone: str | None
) -> User | None:

View File

@@ -55,13 +55,16 @@ class DutyInDb(DutyBase):
class DutyWithUser(DutyInDb):
"""Duty with full_name and event_type for calendar display.
"""Duty with full_name, event_type, and optional contact fields for calendar display.
event_type: only these values are returned; unknown DB values are mapped to "duty" in the API.
phone and username are exposed only to authenticated Mini App users (role-gated).
"""
full_name: str
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
phone: str | None = None
username: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

@@ -22,4 +22,6 @@ def register_handlers(app: Application) -> None:
app.add_handler(group_duty_pin.group_duty_pin_handler)
app.add_handler(group_duty_pin.pin_duty_handler)
app.add_handler(group_duty_pin.refresh_pin_handler)
app.add_handler(group_duty_pin.trust_group_handler)
app.add_handler(group_duty_pin.untrust_group_handler)
app.add_error_handler(errors.error_handler)

View File

@@ -168,6 +168,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if await is_admin_async(update.effective_user.id):
lines.append(t(lang, "help.import_schedule"))
lines.append(t(lang, "help.set_role"))
lines.append(t(lang, "help.trust_group"))
lines.append(t(lang, "help.untrust_group"))
await update.message.reply_text("\n".join(lines))

View File

@@ -6,13 +6,14 @@ from datetime import datetime, timezone
from typing import Literal
import duty_teller.config as config
from telegram import Update
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
from duty_teller.db.session import session_scope
from duty_teller.i18n import get_lang, t
from duty_teller.handlers.common import is_admin_async
from duty_teller.services.group_duty_pin_service import (
get_duty_message_text,
get_message_id,
@@ -21,6 +22,9 @@ from duty_teller.services.group_duty_pin_service import (
save_pin,
delete_pin,
get_all_pin_chat_ids,
is_group_trusted,
trust_group,
untrust_group,
)
logger = logging.getLogger(__name__)
@@ -62,6 +66,54 @@ def _sync_get_message_id(chat_id: int) -> int | None:
return get_message_id(session, chat_id)
def _sync_is_trusted(chat_id: int) -> bool:
"""Check if the group is trusted (sync wrapper for handlers)."""
with session_scope(config.DATABASE_URL) as session:
return is_group_trusted(session, chat_id)
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
"""Add group to trusted list. Returns True if already trusted (no-op)."""
with session_scope(config.DATABASE_URL) as session:
if is_group_trusted(session, chat_id):
return True
trust_group(session, chat_id, added_by_user_id)
return False
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
Telegram returns Button_type_invalid. A plain URL button works everywhere.
"""
if not config.BOT_USERNAME:
return None
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
button = InlineKeyboardButton(
text=t(lang, "pin_duty.view_contacts"),
url=url,
)
return InlineKeyboardMarkup([[button]])
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
"""Remove group from trusted list.
Returns:
(was_trusted, message_id): was_trusted False if group was not in list;
message_id of pinned message if any (for cleanup), else None.
"""
with session_scope(config.DATABASE_URL) as session:
if not is_group_trusted(session, chat_id):
return (False, None)
message_id = get_message_id(session, chat_id)
delete_pin(session, chat_id)
untrust_group(session, chat_id)
return (True, message_id)
async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None
) -> None:
@@ -102,17 +154,40 @@ async def _schedule_next_update(
async def _refresh_pin_for_chat(
context: ContextTypes.DEFAULT_TYPE, chat_id: int
) -> Literal["updated", "no_message", "failed"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
) -> Literal["updated", "no_message", "failed", "untrusted"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
Uses single DB session for message_id, text, next_shift_end (consolidated).
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
Returns:
"updated" if the message was sent, pinned and saved successfully;
"no_message" if there is no pin record for this chat;
"failed" if send_message or permissions failed.
"failed" if send_message or permissions failed;
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
"""
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if old_message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(
chat_id=chat_id, message_id=old_message_id
)
except (BadRequest, Forbidden):
pass
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
return "untrusted"
message_id, text, next_end = await loop.run_in_executor(
None,
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
@@ -120,8 +195,13 @@ async def _refresh_pin_for_chat(
if message_id is None:
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return "no_message"
old_message_id = message_id
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
@@ -140,6 +220,16 @@ async def _refresh_pin_for_chat(
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
if old_message_id is not None:
try:
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
except (BadRequest, Forbidden) as e:
logger.warning(
"Could not delete old pinned message %s in chat_id=%s: %s",
old_message_id,
chat_id,
e,
)
await _schedule_next_update(context.application, chat_id, next_end)
return "updated"
@@ -175,12 +265,27 @@ async def my_chat_member_handler(
ChatMemberStatus.BANNED,
):
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
lang = get_lang(update.effective_user)
try:
await context.bot.send_message(
chat_id=chat_id,
text=t(lang, "group.not_trusted"),
)
except (BadRequest, Forbidden):
pass
return
lang = get_lang(update.effective_user)
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return
@@ -244,13 +349,21 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
@@ -301,13 +414,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
result = await _refresh_pin_for_chat(context, chat_id)
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /trust_group: add current group to trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "trust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
already_trusted = await loop.run_in_executor(
None,
lambda: _sync_trust_group(
chat_id, update.effective_user.id if update.effective_user else None
),
)
if already_trusted:
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
return
await update.message.reply_text(t(lang, "trust_group.added"))
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message after trust_group chat_id=%s: %s",
chat_id,
e,
)
return
try:
await context.bot.pin_chat_message(
chat_id=chat_id,
message_id=msg.message_id,
disable_notification=not config.DUTY_PIN_NOTIFY,
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "untrust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
was_trusted, message_id = await loop.run_in_executor(
None, _sync_untrust_group, chat_id
)
if not was_trusted:
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
return
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except (BadRequest, Forbidden):
pass
await update.message.reply_text(t(lang, "untrust_group.removed"))
group_duty_pin_handler = ChatMemberHandler(
my_chat_member_handler,
ChatMemberHandler.MY_CHAT_MEMBER,
)
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)

View File

@@ -18,6 +18,19 @@ MESSAGES: dict[str, dict[str, str]] = {
"refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.",
"refresh_pin.updated": "Pinned duty message updated.",
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).",
"refresh_pin.untrusted": "Group was removed from trusted list; pin record cleared.",
"trust_group.added": "Group added to trusted list.",
"trust_group.already_trusted": "This group is already trusted.",
"trust_group.group_only": "The /trust_group command works only in groups.",
"untrust_group.removed": "Group removed from trusted list.",
"untrust_group.not_trusted": "This group is not in the trusted list.",
"untrust_group.group_only": "The /untrust_group command works only in groups.",
"group.not_trusted": (
"This group is not authorized to receive duty data. "
"An administrator can add the group with /trust_group."
),
"help.trust_group": "/trust_group — In a group: add group to trusted list (admin only)",
"help.untrust_group": "/untrust_group — In a group: remove group from trusted list (admin only)",
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
"calendar_link.access_denied": "Access denied.",
"calendar_link.success": (
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"administrator with «Pin messages» permission, then send /pin_duty in the "
"chat — the current message will be pinned."
),
"pin_duty.view_contacts": "View contacts",
"duty.no_duty": "No duty at the moment.",
"duty.label": "Duty:",
"import.admin_only": "Access for administrators only.",
@@ -74,6 +88,13 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Access denied",
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
"dates.from_after_to": "from date must not be after to",
"contact.show": "Contacts",
"contact.back": "Back",
"current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift",
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar",
},
"ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -92,6 +113,19 @@ MESSAGES: dict[str, dict[str, str]] = {
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).",
"refresh_pin.untrusted": "Группа удалена из доверенных; запись о закреплении сброшена.",
"trust_group.added": "Группа добавлена в доверенные.",
"trust_group.already_trusted": "Эта группа уже в доверенных.",
"trust_group.group_only": "Команда /trust_group работает только в группах.",
"untrust_group.removed": "Группа удалена из доверенных.",
"untrust_group.not_trusted": "Эта группа не в доверенных.",
"untrust_group.group_only": "Команда /untrust_group работает только в группах.",
"group.not_trusted": (
"Эта группа не авторизована для получения данных дежурных. "
"Администратор может добавить группу командой /trust_group."
),
"help.trust_group": "/trust_group — В группе: добавить группу в доверенные (только админ)",
"help.untrust_group": "/untrust_group — В группе: удалить группу из доверенных (только админ)",
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
"calendar_link.access_denied": "Доступ запрещён.",
"calendar_link.success": (
@@ -116,6 +150,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
"pin_duty.view_contacts": "Контакты",
"duty.no_duty": "Сейчас дежурства нет.",
"duty.label": "Дежурство:",
"import.admin_only": "Доступ только для администраторов.",
@@ -136,5 +171,12 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Доступ запрещён",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to",
"contact.show": "Контакты",
"contact.back": "Назад",
"current_duty.title": "Текущее дежурство",
"current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю",
},
}

View File

@@ -13,6 +13,15 @@ from duty_teller.config import require_bot_token
from duty_teller.handlers import group_duty_pin, register_handlers
from duty_teller.utils.http_client import safe_urlopen
async def _resolve_bot_username(application) -> None:
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
if not config.BOT_USERNAME:
me = await application.bot.get_me()
config.BOT_USERNAME = (me.username or "").lower()
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
@@ -69,6 +78,7 @@ def main() -> None:
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build()
)
register_handlers(app)

View File

@@ -13,6 +13,9 @@ from duty_teller.db.repository import (
save_group_duty_pin,
delete_group_duty_pin,
get_all_group_duty_pin_chat_ids,
is_trusted_group,
add_trusted_group,
remove_trusted_group,
)
from duty_teller.i18n import t
from duty_teller.utils.dates import parse_utc_iso
@@ -82,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
f"🕐 {label} {time_range}",
f"👤 {user.full_name}",
]
if user.phone:
lines.append(f"📞 {user.phone}")
if user.username:
lines.append(f"@{user.username}")
return "\n".join(lines)
@@ -164,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
List of chat ids.
"""
return get_all_group_duty_pin_chat_ids(session)
def is_group_trusted(session: Session, chat_id: int) -> bool:
"""Check if the group is in the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return is_trusted_group(session, chat_id)
def trust_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> None:
"""Add the group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
"""
add_trusted_group(session, chat_id, added_by_user_id)
def untrust_group(session: Session, chat_id: int) -> None:
"""Remove the group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
remove_trusted_group(session, chat_id)

View File

@@ -71,3 +71,31 @@ class TestValidateDutyDates:
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "From after to message"
mock_t.assert_called_with("ru", "dates.from_after_to")
class TestFetchDutiesResponse:
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
def test_fetch_duties_response_includes_phone_and_username(self):
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
from types import SimpleNamespace
from duty_teller.db.schemas import DutyWithUser
duty = SimpleNamespace(
id=1,
user_id=10,
start_at="2025-01-15T09:00:00Z",
end_at="2025-01-15T18:00:00Z",
event_type="duty",
)
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
with patch.object(deps, "get_duties", return_value=rows):
result = deps.fetch_duties_response(
type("Session", (), {})(), "2025-01-01", "2025-01-31"
)
assert len(result) == 1
assert isinstance(result[0], DutyWithUser)
assert result[0].full_name == "Alice"
assert result[0].phone == "+79001234567"
assert result[0].username == "alice_dev"

View File

@@ -254,7 +254,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
)
def fake_get_duties(session, from_date, to_date):
return [(fake_duty, "User A")]
# get_duties returns (Duty, full_name, phone, username) tuples.
return [(fake_duty, "User A", "+79001234567", "user_a")]
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
r = client.get(
@@ -266,6 +267,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert len(data) == 1
assert data[0]["event_type"] == "duty"
assert data[0]["full_name"] == "User A"
assert data[0].get("phone") == "+79001234567"
assert data[0].get("username") == "user_a"
def test_calendar_ical_team_404_invalid_token_format(client):
@@ -311,7 +314,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
end_at="2026-06-16T18:00:00Z",
event_type="vacation",
)
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
# get_duties returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [
(duty, "User A", None, None),
(non_duty, "User B", None, None),
]
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
token = "y" * 43
@@ -371,7 +378,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
mock_get_duties.return_value = [(duty, "User A")]
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [(duty, "User A", None, None)]
mock_build_ics.return_value = (
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
)
@@ -415,7 +423,8 @@ def test_calendar_ical_ignores_unknown_query_params(
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
mock_get_duties.return_value = [(duty, "User A")]
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [(duty, "User A", None, None)]
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
token = "z" * 43

View File

@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
assert result == "No duty"
def test_with_duty_and_user_returns_formatted(self):
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
duty = SimpleNamespace(
start_at="2025-01-15T09:00:00Z",
end_at="2025-01-15T18:00:00Z",
@@ -94,9 +95,10 @@ class TestFormatDutyMessage:
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
assert "Иван Иванов" in result
assert "+79001234567" in result or "79001234567" in result
assert "@ivan" in result
assert "Duty" in result
# Contact info is restricted to Mini App; not shown in pinned group message.
assert "+79001234567" not in result and "79001234567" not in result
assert "@ivan" not in result
class TestGetDutyMessageText:

View File

@@ -11,6 +11,13 @@ import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod
@pytest.fixture(autouse=True)
def no_mini_app_url():
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
with patch.object(config, "BOT_USERNAME", ""):
yield
class TestSyncWrappers:
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
@@ -76,6 +83,30 @@ class TestSyncWrappers:
# --- _schedule_next_update ---
def test_get_contact_button_markup_empty_username_returns_none():
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
with patch.object(config, "BOT_USERNAME", ""):
assert mod._get_contact_button_markup("en") is None
def test_get_contact_button_markup_returns_markup_when_username_set():
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
from telegram import InlineKeyboardMarkup
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
with patch.object(mod, "t", return_value="View contacts"):
result = mod._get_contact_button_markup("en")
assert result is not None
assert isinstance(result, InlineKeyboardMarkup)
assert len(result.inline_keyboard) == 1
assert len(result.inline_keyboard[0]) == 1
btn = result.inline_keyboard[0][0]
assert btn.text == "View contacts"
assert btn.url.startswith("https://t.me/")
assert "startapp=duty" in btn.url
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
@pytest.mark.asyncio
async def test_schedule_next_update_job_queue_none_returns_early():
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
@@ -127,7 +158,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
@pytest.mark.asyncio
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
@@ -137,24 +168,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=123, text="Current duty", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=123, message_id=999, disable_notification=False
)
mock_save.assert_called_once_with(123, 999)
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
@pytest.mark.asyncio
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
context.job = MagicMock()
context.job.data = {"chat_id": 123}
context.bot = MagicMock()
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock(
side_effect=BadRequest("Message to delete not found")
)
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
mock_save.assert_called_once_with(123, 999)
mock_schedule.assert_called_once_with(context.application, 123, None)
mock_logger.warning.assert_called_once()
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
@pytest.mark.asyncio
@@ -166,10 +243,11 @@ async def test_update_group_pin_no_message_id_skips():
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
context.bot.send_message.assert_not_called()
@@ -185,11 +263,14 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule:
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
context.bot.unpin_chat_message.assert_not_called()
context.bot.pin_chat_message.assert_not_called()
mock_schedule.assert_called_once_with(context.application, 111, None)
@@ -212,16 +293,19 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=222, text="Text", reply_markup=None
)
mock_save.assert_not_called()
mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
@@ -230,7 +314,7 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
@pytest.mark.asyncio
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
new_msg = MagicMock()
new_msg.message_id = 777
context = MagicMock()
@@ -240,23 +324,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", False):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=333, text="Text", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=333, message_id=777, disable_notification=True
)
mock_save.assert_called_once_with(333, 777)
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
mock_schedule.assert_called_once_with(context.application, 333, None)
@@ -295,16 +384,41 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
context.bot.pin_chat_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=5, disable_notification=True
)
update.message.reply_text.assert_called_once_with("Pinned")
@pytest.mark.asyncio
async def test_pin_duty_cmd_untrusted_group_rejects():
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
@@ -327,21 +441,26 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=42, disable_notification=True
)
@@ -366,15 +485,20 @@ async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
context.application = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Failed"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "pin_duty.failed")
mock_save.assert_not_called()
@@ -403,19 +527,26 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty", reply_markup=None
)
mock_save.assert_called_once_with(100, 43)
update.message.reply_text.assert_called_once_with("Make me admin to pin")
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
@@ -438,10 +569,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
)
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed to pin")
mock_t.assert_called_with("en", "pin_duty.failed")
@@ -482,12 +614,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Updated")
mock_t.assert_called_with("en", "refresh_pin.updated")
@@ -505,12 +638,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message")
mock_t.assert_called_with("en", "refresh_pin.no_message")
@@ -528,16 +662,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "refresh_pin.failed")
@pytest.mark.asyncio
async def test_refresh_pin_cmd_untrusted_group_rejects():
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock()
) as mock_refresh:
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
mock_refresh.assert_not_called()
# --- my_chat_member_handler ---
@@ -586,12 +746,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@pytest.mark.asyncio
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
@pytest.mark.asyncio
async def test_my_chat_member_handler_trusted_group_sends_duty():
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@@ -617,13 +845,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
assert context.bot.send_message.call_count >= 2
pin_hint_calls = [
c
@@ -674,3 +907,196 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
assert mock_schedule.call_count == 2
mock_schedule.assert_any_call(application, 10, None)
mock_schedule.assert_any_call(application, 20, None)
# --- _refresh_pin_for_chat untrusted ---
@pytest.mark.asyncio
async def test_refresh_pin_for_chat_untrusted_removes_pin():
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
mock_job = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=11):
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
result = await mod._refresh_pin_for_chat(context, 100)
assert result == "untrusted"
mock_delete_pin.assert_called_once_with(100)
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
# --- trust_group_cmd / untrust_group_cmd ---
@pytest.mark.asyncio
async def test_trust_group_cmd_non_admin_rejects():
"""trust_group_cmd: non-admin -> reply import.admin_only."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Admin only"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Admin only")
mock_t.assert_called_with("en", "import.admin_only")
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_adds_group():
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 50
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Added"
with patch.object(
config, "DUTY_PIN_NOTIFY", False
):
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_any_call("Added")
mock_t.assert_any_call("en", "trust_group.added")
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=50, disable_notification=True
)
mock_save.assert_called_once_with(100, 50)
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=True):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Already trusted"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Already trusted")
mock_t.assert_called_with("en", "trust_group.already_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_untrust_group_cmd_removes_group():
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
mock_job = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Removed"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Removed")
mock_t.assert_called_with("en", "untrust_group.removed")
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
@pytest.mark.asyncio
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not trusted"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not trusted")
mock_t.assert_called_with("en", "untrust_group.not_trusted")

View File

@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
assert "2026-02-16T06:00:00Z" in starts
assert "2026-02-17T06:00:00Z" in starts
assert "2026-02-18T06:00:00Z" in starts
for d, _ in duties:
for d, *_ in duties:
assert d.event_type == "duty"

View File

@@ -0,0 +1,84 @@
"""Unit tests for trusted_groups repository functions."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from duty_teller.db.models import Base
from duty_teller.db.repository import (
is_trusted_group,
add_trusted_group,
remove_trusted_group,
get_all_trusted_group_ids,
)
@pytest.fixture
def session():
"""In-memory SQLite session with all tables (including trusted_groups)."""
engine = create_engine(
"sqlite:///:memory:", connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
s = Session()
try:
yield s
finally:
s.close()
engine.dispose()
def test_is_trusted_group_empty_returns_false(session):
"""is_trusted_group returns False when no record exists."""
assert is_trusted_group(session, 100) is False
assert is_trusted_group(session, 200) is False
def test_add_trusted_group_creates_record(session):
"""add_trusted_group creates a record and returns TrustedGroup."""
record = add_trusted_group(session, 100, added_by_user_id=12345)
assert record.chat_id == 100
assert record.added_by_user_id == 12345
assert record.added_at is not None
def test_is_trusted_group_after_add_returns_true(session):
"""is_trusted_group returns True after add_trusted_group."""
add_trusted_group(session, 100)
assert is_trusted_group(session, 100) is True
assert is_trusted_group(session, 101) is False
def test_add_trusted_group_without_added_by_user_id(session):
"""add_trusted_group accepts added_by_user_id None."""
record = add_trusted_group(session, 200, added_by_user_id=None)
assert record.chat_id == 200
assert record.added_by_user_id is None
def test_remove_trusted_group_removes_record(session):
"""remove_trusted_group removes the record."""
add_trusted_group(session, 100)
assert is_trusted_group(session, 100) is True
remove_trusted_group(session, 100)
assert is_trusted_group(session, 100) is False
def test_remove_trusted_group_idempotent(session):
"""remove_trusted_group on non-existent chat_id does not raise."""
remove_trusted_group(session, 999)
def test_get_all_trusted_group_ids_empty(session):
"""get_all_trusted_group_ids returns empty list when no trusted groups."""
assert get_all_trusted_group_ids(session) == []
def test_get_all_trusted_group_ids_returns_added_chats(session):
"""get_all_trusted_group_ids returns all trusted chat_ids."""
add_trusted_group(session, 10)
add_trusted_group(session, 20)
add_trusted_group(session, 30)
ids = get_all_trusted_group_ids(session)
assert set(ids) == {10, 20, 30}

94
webapp/css/base.css Normal file
View File

@@ -0,0 +1,94 @@
/* === Variables & themes */
:root {
--bg: #1a1b26;
--surface: #24283b;
--text: #c0caf5;
--muted: #565f89;
--accent: #7aa2f7;
--duty: #9ece6a;
--today: #bb9af7;
--unavailable: #e0af68;
--vacation: #7dcfff;
--error: #f7768e;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
[data-theme="light"] {
--bg: var(--tg-theme-bg-color, #f0f1f3);
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
--text: var(--tg-theme-text-color, #343b58);
--muted: var(--tg-theme-hint-color, #6b7089);
--accent: var(--tg-theme-link-color, #2e7de0);
--duty: #587d0a;
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
--error: #c43b3b;
}
/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
[data-theme="dark"] {
--bg: var(--tg-theme-bg-color, #17212b);
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
--text: var(--tg-theme-text-color, #f5f5f5);
--muted: var(--tg-theme-hint-color, #708499);
--accent: var(--tg-theme-link-color, #6ab3f3);
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
--duty: #5c9b4a;
--unavailable: #b8860b;
--vacation: #5a9bb8;
--error: #e06c75;
}
/* === Layout & base */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
* {
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-top: 0px;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
[data-theme="light"] .container {
border-radius: 12px;
}

197
webapp/css/calendar.css Normal file
View File

@@ -0,0 +1,197 @@
/* === Calendar: header, nav, weekdays, grid, day cells */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header[hidden],
.weekdays[hidden] {
display: none !important;
}
.nav {
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--surface);
color: var(--accent);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav:focus {
outline: none;
}
.nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.nav:active {
transform: scale(0.95);
}
.nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding-bottom: 12px;
margin-bottom: 4px;
touch-action: pan-y;
transition: box-shadow var(--transition-fast) ease-out;
}
.calendar-sticky.is-scrolled {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 16px;
}
.day {
position: relative;
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);
min-width: 0;
min-height: 0;
overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.day.other-month {
opacity: 0.4;
}
.day.today {
background: var(--today);
color: var(--bg);
}
.day.has-duty .num {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
.day.today.holiday {
background: var(--today);
color: var(--bg);
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
}
.day {
cursor: pointer;
}
.day:focus {
outline: none;
}
.day:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day:active {
transform: scale(0.98);
}
.day-indicator {
display: flex;
justify-content: center;
width: 65%;
margin-top: 6px;
}
.day-indicator-dot {
flex: 1;
height: 5px;
border-radius: 0;
}
.day-indicator-dot:only-child {
flex: 0 0 6px;
height: 6px;
border-radius: 50%;
}
.day-indicator-dot:first-child:not(:only-child) {
border-radius: 3px 0 0 3px;
}
.day-indicator-dot:last-child:not(:only-child) {
border-radius: 0 3px 3px 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* On "today" cell: dots darkened for contrast on --today background */
.day.today .day-indicator-dot.duty {
background: color-mix(in srgb, var(--duty) 65%, var(--bg));
}
.day.today .day-indicator-dot.unavailable {
background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
}
.day.today .day-indicator-dot.vacation {
background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
}
.day.today .day-indicator-dot.events {
background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}

219
webapp/css/day-detail.css Normal file
View File

@@ -0,0 +1,219 @@
/* === Day detail panel (popover / bottom sheet) */
/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
body.day-detail-sheet-open {
position: fixed;
left: 0;
right: 0;
overflow: hidden;
}
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal) ease-out;
}
.day-detail-overlay.day-detail-overlay--visible {
opacity: 1;
pointer-events: auto;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
/* Комфортный отступ снизу: safe area + дополнительное поле */
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform var(--transition-normal) var(--ease-out);
}
.day-detail-panel--sheet.day-detail-panel--open {
transform: translateY(0);
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.day-detail-close:focus {
outline: none;
}
.day-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
/* Contact info: phone (tel:) and Telegram username links in day detail */
.day-detail-contact-row {
margin-top: 4px;
font-size: 0.85rem;
color: var(--muted);
}
.day-detail-contact {
display: inline-block;
margin-right: 0.75em;
}
.day-detail-contact:last-child {
margin-right: 0;
}
.day-detail-contact-link,
.day-detail-contact-phone,
.day-detail-contact-username {
color: var(--accent);
text-decoration: none;
}
.day-detail-contact-link:hover,
.day-detail-contact-phone:hover,
.day-detail-contact-username:hover {
text-decoration: underline;
}
.day-detail-contact-link:focus,
.day-detail-contact-phone:focus,
.day-detail-contact-username:focus {
outline: none;
}
.day-detail-contact-link:focus-visible,
.day-detail-contact-phone:focus-visible,
.day-detail-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.day-markers {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 2px;
align-items: center;
margin-top: 2px;
min-width: 0;
}

330
webapp/css/duty-list.css Normal file
View File

@@ -0,0 +1,330 @@
/* === Duty list & timeline */
.duty-list {
font-size: 0.9rem;
}
.duty-list h2 {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 8px 0;
}
.duty-list-day {
margin-bottom: 16px;
}
.duty-list-day--today .duty-list-day-title {
color: var(--today);
font-weight: 700;
}
.duty-list-day--today .duty-list-day-title::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
background: var(--today);
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
/* Timeline: dates | track (line + dot) | cards */
.duty-list.duty-timeline {
position: relative;
}
.duty-list.duty-timeline::before {
content: "";
position: absolute;
left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
top: 0;
bottom: 0;
width: 2px;
background: var(--muted);
pointer-events: none;
}
.duty-timeline-day {
margin-bottom: 0;
}
.duty-timeline-day--today {
scroll-margin-top: 200px;
}
.duty-timeline-row {
display: grid;
grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
gap: 0 4px;
align-items: start;
margin-bottom: 8px;
min-height: 1px;
}
.duty-timeline-date {
position: relative;
font-size: 0.8rem;
color: var(--muted);
padding-top: 10px;
padding-bottom: 10px;
flex-shrink: 0;
overflow: visible;
}
.duty-timeline-date::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
color-mix(in srgb, var(--muted) 40%, transparent) 0%,
color-mix(in srgb, var(--muted) 40%, transparent) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
.duty-timeline-day--today .duty-timeline-date {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: 4px;
color: var(--today);
font-weight: 600;
}
.duty-timeline-day--today .duty-timeline-date::before,
.duty-timeline-day--today .duty-timeline-date::after {
display: none;
}
.duty-timeline-date-label,
.duty-timeline-date-day {
display: block;
line-height: 1.25;
}
.duty-timeline-date-day {
align-self: flex-start;
text-align: left;
padding-left: 0;
margin-left: 0;
}
.duty-timeline-date-dot {
display: block;
width: 100%;
height: 8px;
min-height: 8px;
position: relative;
flex-shrink: 0;
}
.duty-timeline-date-dot::before {
content: "";
position: absolute;
left: 0;
top: 50%;
margin-top: -1px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: color-mix(in srgb, var(--today) 45%, transparent);
}
.duty-timeline-date-dot::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
top: 50%;
margin-top: -3px;
width: 2px;
height: 6px;
background: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
color: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
color: var(--muted);
font-weight: 400;
font-size: 0.75rem;
}
.duty-timeline-track {
min-width: 0;
}
.duty-timeline-card-wrap {
min-width: 0;
}
/* Flip-card: front = duty info + button, back = contacts */
.duty-flip-card {
perspective: 600px;
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.duty-flip-inner {
transition: transform 0.4s;
transform-style: preserve-3d;
position: relative;
min-height: 0;
background: transparent;
}
.duty-flip-card[data-flipped="true"] .duty-flip-inner {
transform: rotateY(180deg);
}
.duty-flip-front {
position: relative;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.duty-flip-back {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
.duty-flip-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--surface);
color: var(--accent);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
.duty-flip-btn:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
}
.duty-flip-btn:focus {
outline: none;
}
.duty-flip-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-timeline-card.duty-item,
.duty-list .duty-item {
display: grid;
grid-template-columns: 1fr;
gap: 2px 0;
align-items: baseline;
padding: 8px 10px;
margin-bottom: 0;
border-radius: 8px;
background: var(--surface);
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: var(--unavailable);
}
.duty-item--vacation {
border-left-color: var(--vacation);
}
.duty-item .duty-item-type {
grid-column: 1;
grid-row: 1;
font-size: 0.75rem;
color: var(--muted);
}
.duty-item .name {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
font-weight: 600;
}
.duty-item .time {
grid-column: 1;
grid-row: 2;
align-self: start;
font-size: 0.8rem;
color: var(--muted);
}
.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
/* Contact info: phone and Telegram username links in duty timeline cards */
.duty-contact-row {
grid-column: 1;
grid-row: 4;
font-size: 0.8rem;
color: var(--muted);
margin-top: 2px;
}
.duty-contact-link,
.duty-contact-phone,
.duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.duty-contact-link:hover,
.duty-contact-phone:hover,
.duty-contact-username:hover {
text-decoration: underline;
}
.duty-contact-link:focus,
.duty-contact-phone:focus,
.duty-contact-username:focus {
outline: none;
}
.duty-contact-link:focus-visible,
.duty-contact-phone:focus-visible,
.duty-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-item--current {
border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface));
}

70
webapp/css/hints.css Normal file
View File

@@ -0,0 +1,70 @@
/* === Hints (tooltips) */
.calendar-event-hint {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(98vw, 900px);
min-width: 0;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
overflow: visible;
transform: translateY(-100%);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.calendar-event-hint:not(.calendar-event-hint--visible) {
opacity: 0;
}
.calendar-event-hint.calendar-event-hint--visible {
opacity: 1;
}
.calendar-event-hint.below {
transform: none;
}
.calendar-event-hint-title {
margin-bottom: 4px;
font-weight: 600;
}
.calendar-event-hint-rows {
display: table;
width: min-content;
table-layout: auto;
border-collapse: separate;
border-spacing: 0 2px;
}
.calendar-event-hint-row {
display: table-row;
white-space: nowrap;
}
.calendar-event-hint-row .calendar-event-hint-time {
display: table-cell;
white-space: nowrap;
width: 1%;
vertical-align: top;
text-align: right;
padding-right: 0.15em;
}
.calendar-event-hint-row .calendar-event-hint-sep {
display: table-cell;
width: 1em;
vertical-align: top;
padding-right: 0.1em;
}
.calendar-event-hint-row .calendar-event-hint-name {
display: table-cell;
white-space: nowrap !important;
}

45
webapp/css/markers.css Normal file
View File

@@ -0,0 +1,45 @@
/* === Markers (duty / unavailable / vacation) */
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: box-shadow var(--transition-fast) ease-out;
}
.duty-marker {
color: var(--duty);
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.unavailable-marker {
color: var(--unavailable);
background: color-mix(in srgb, var(--unavailable) 25%, transparent);
}
.vacation-marker {
color: var(--vacation);
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}

306
webapp/css/states.css Normal file
View File

@@ -0,0 +1,306 @@
/* === Loading / error / access denied / current duty view */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
color: var(--muted);
text-align: center;
}
.loading__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading__spinner {
animation: none;
border-top-color: var(--accent);
border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.loading, .error {
text-align: center;
padding: 12px;
color: var(--muted);
}
.error,
.access-denied {
transition: opacity 0.2s ease-out;
}
.error {
color: var(--error);
}
.error[hidden], .loading.hidden,
.current-duty-view.hidden {
display: none !important;
}
/* Current duty view (Mini App deep link startapp=duty) */
[data-view="currentDuty"] .calendar-sticky,
[data-view="currentDuty"] .duty-list {
display: none !important;
}
.current-duty-view {
padding: 24px 16px;
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.current-duty-card {
background: var(--surface);
border-radius: 12px;
border-top: 3px solid var(--duty);
padding: 24px;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: card-appear 0.3s ease-out;
}
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.current-duty-title {
margin: 0 0 16px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.current-duty-live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--duty);
margin-right: 8px;
animation: pulse-dot 1.5s ease-in-out infinite;
vertical-align: middle;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.current-duty-card {
animation: none;
}
.current-duty-live-dot {
animation: none;
opacity: 1;
}
}
.current-duty-name {
margin: 0 0 8px 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--duty);
}
.current-duty-shift {
margin: 0 0 12px 0;
font-size: 0.95rem;
color: var(--muted);
}
.current-duty-remaining {
margin: 0 0 12px 0;
font-size: 0.95rem;
color: var(--muted);
}
/* No-duty state: icon + prominent text */
.current-duty-card--no-duty {
border-top-color: var(--muted);
}
.current-duty-no-duty-wrap {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 0 20px 0;
padding: 8px 0;
}
.current-duty-no-duty-icon {
display: block;
color: var(--muted);
margin-bottom: 16px;
line-height: 0;
}
.current-duty-no-duty-icon svg {
display: block;
opacity: 0.7;
}
.current-duty-no-duty {
margin: 0;
font-size: 1.15rem;
font-weight: 500;
color: var(--muted);
line-height: 1.4;
max-width: 20em;
}
.current-duty-error {
margin: 0 0 16px 0;
color: var(--error);
}
.current-duty-error {
color: var(--error);
}
.current-duty-contact-row {
margin: 12px 0 20px 0;
}
.current-duty-contact-row--blocks {
display: flex;
flex-direction: column;
gap: 8px;
}
.current-duty-contact-block {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 12px 16px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
text-decoration: none;
font-size: 1rem;
font-weight: 500;
transition: background 0.2s ease;
}
.current-duty-contact-block:hover,
.current-duty-contact-block:focus-visible {
background: color-mix(in srgb, var(--accent) 20%, transparent);
text-decoration: none;
color: var(--accent);
}
.current-duty-contact-block:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.current-duty-contact-block svg {
flex-shrink: 0;
}
.current-duty-contact {
display: inline-block;
margin-right: 12px;
font-size: 0.95rem;
}
.current-duty-contact-link,
.current-duty-contact-phone,
.current-duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.current-duty-contact-link:hover,
.current-duty-contact-phone:hover,
.current-duty-contact-username:hover {
text-decoration: underline;
}
.current-duty-back-btn {
display: block;
width: 100%;
padding: 12px 16px;
margin-top: 8px;
font-size: 1rem;
font-weight: 500;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
}
.current-duty-back-btn:hover {
opacity: 0.9;
}
.current-duty-back-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.current-duty-loading {
text-align: center;
color: var(--muted);
}
.access-denied {
text-align: center;
padding: 24px 12px;
color: var(--muted);
}
.access-denied p {
margin: 0 0 8px 0;
}
.access-denied p:first-child {
color: var(--error);
font-weight: 600;
}
.access-denied .access-denied-detail {
margin-top: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.access-denied[hidden] {
display: none !important;
}

View File

@@ -1,33 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="favicon.png" type="image/png">
<title>Календарь дежурств</title>
<link rel="stylesheet" href="style.css">
<title></title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/calendar.css">
<link rel="stylesheet" href="css/day-detail.css">
<link rel="stylesheet" href="css/hints.css">
<link rel="stylesheet" href="css/markers.css">
<link rel="stylesheet" href="css/duty-list.css">
<link rel="stylesheet" href="css/states.css">
</head>
<body>
<div class="container">
<div class="calendar-sticky" id="calendarSticky">
<header class="header">
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц"></button>
<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>
<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>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="calendar" id="calendar"></div>
</div>
<div class="duty-list" id="dutyList"></div>
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text">Загрузка…</span></div>
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div>
<div class="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden>
<p>Доступ запрещён.</p>
</div>
<div class="access-denied" id="accessDenied" hidden></div>
<div id="currentDutyView" class="current-duty-view hidden"></div>
</div>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script type="module" src="js/main.js"></script>
<script type="importmap">
{
"scopes": {
"./js/": {
"./js/i18n.js": "./js/i18n.js?v=1"
}
}
}
</script>
<script type="module" src="js/main.js?v=4"></script>
</body>
</html>

View File

@@ -9,16 +9,31 @@ import { t } from "./i18n.js";
/**
* Build fetch options with init data header, Accept-Language and timeout abort.
* Optional external signal (e.g. from loadMonth) aborts this request when triggered.
* @param {string} initData - Telegram init data
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
* @param {AbortSignal} [externalSignal] - when aborted, cancels this request
* @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }}
*/
export function buildFetchOptions(initData) {
export function buildFetchOptions(initData, externalSignal) {
const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
return { headers, signal: controller.signal, timeoutId };
const onAbort = () => controller.abort();
const cleanup = () => {
clearTimeout(timeoutId);
if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
};
if (externalSignal) {
if (externalSignal.aborted) {
cleanup();
controller.abort();
} else {
externalSignal.addEventListener("abort", onAbort);
}
}
return { headers, signal: controller.signal, cleanup };
}
/**
@@ -26,70 +41,69 @@ export function buildFetchOptions(initData) {
* Caller checks res.ok, res.status, res.json().
* @param {string} path - e.g. "/api/duties"
* @param {{ from?: string, to?: string }} params - query params
* @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation
* @returns {Promise<Response>} - raw response
*/
export async function apiGet(path, params = {}) {
export async function apiGet(path, params = {}, options = {}) {
const base = window.location.origin;
const query = new URLSearchParams(params).toString();
const url = query ? `${base}${path}?${query}` : `${base}${path}`;
const initData = getInitData();
const opts = buildFetchOptions(initData);
const opts = buildFetchOptions(initData, options.signal);
try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
return res;
} finally {
clearTimeout(opts.timeoutId);
opts.cleanup();
}
}
/**
* Fetch duties for date range. Throws ACCESS_DENIED error on 403.
* AbortError is rethrown when the request is cancelled (e.g. stale loadMonth).
* @param {string} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>}
*/
export async function fetchDuties(from, to) {
try {
const res = await apiGet("/api/duties", { from, to });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
} catch (parseErr) {
/* ignore */
export async function fetchDuties(from, to, signal) {
const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
} catch (parseErr) {
/* ignore */
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
} catch (e) {
if (e.name === "AbortError") {
throw new Error(t(state.lang, "error_network"));
}
throw e;
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
}
/**
* Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403.
* Rethrows AbortError when the request is cancelled (e.g. stale loadMonth).
* @param {string} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>}
*/
export async function fetchCalendarEvents(from, to) {
export async function fetchCalendarEvents(from, to, signal) {
try {
const res = await apiGet("/api/calendar-events", { from, to });
const res = await apiGet("/api/calendar-events", { from, to }, { signal });
if (!res.ok) return [];
return res.json();
} catch (e) {
if (e.name === "AbortError") throw e;
return [];
}
}

212
webapp/js/api.test.js Normal file
View File

@@ -0,0 +1,212 @@
/**
* Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError).
*/
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js";
import { state } from "./dom.js";
describe("buildFetchOptions", () => {
beforeEach(() => {
state.lang = "ru";
});
it("sets X-Telegram-Init-Data when initData provided", () => {
const opts = buildFetchOptions("init-data-string");
expect(opts.headers["X-Telegram-Init-Data"]).toBe("init-data-string");
opts.cleanup();
});
it("omits X-Telegram-Init-Data when initData empty", () => {
const opts = buildFetchOptions("");
expect(opts.headers["X-Telegram-Init-Data"]).toBeUndefined();
opts.cleanup();
});
it("sets Accept-Language from state.lang", () => {
state.lang = "en";
const opts = buildFetchOptions("");
expect(opts.headers["Accept-Language"]).toBe("en");
opts.cleanup();
});
it("returns signal and cleanup function", () => {
const opts = buildFetchOptions("");
expect(opts.signal).toBeDefined();
expect(typeof opts.cleanup).toBe("function");
opts.cleanup();
});
it("cleanup clears timeout and removes external abort listener", () => {
const controller = new AbortController();
const opts = buildFetchOptions("", controller.signal);
opts.cleanup();
controller.abort();
});
});
describe("fetchDuties", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("test-init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1 }]),
});
const result = await fetchDuties("2025-02-01", "2025-02-28");
expect(result).toEqual([{ id: 1 }]);
});
it("throws ACCESS_DENIED on 403 with server detail from body", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Custom access denied" }),
});
await expect(fetchDuties("2025-02-01", "2025-02-28")).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Custom access denied",
});
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => {
return Promise.reject(abortError);
});
await expect(
fetchDuties("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("apiGet", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "en";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("builds URL with path and query params and returns response", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true, status: 200 });
});
await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" });
expect(capturedUrl).toContain("/api/duties");
expect(capturedUrl).toContain("from=2025-02-01");
expect(capturedUrl).toContain("to=2025-02-28");
});
it("omits query string when params empty", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true });
});
await apiGet("/api/health", {});
expect(capturedUrl).toBe(window.location.origin + "/api/health");
});
it("passes X-Telegram-Init-Data and Accept-Language headers", async () => {
let capturedOpts = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedOpts = opts;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" });
expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data");
expect(capturedOpts?.headers["Accept-Language"]).toBe("en");
});
it("passes an abort signal to fetch when options.signal provided", async () => {
const controller = new AbortController();
let capturedSignal = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedSignal = opts.signal;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", {}, { signal: controller.signal });
expect(capturedSignal).toBeDefined();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
});
describe("fetchCalendarEvents", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON array on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
});
it("returns empty array on non-OK response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("returns empty array on 403 (does not throw)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError));
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});

155
webapp/js/auth.test.js Normal file
View File

@@ -0,0 +1,155 @@
/**
* Unit tests for auth: getTgWebAppDataFromHash, getInitData, isLocalhost.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getTgWebAppDataFromHash,
getInitData,
isLocalhost,
hasTelegramHashButNoInitData,
} from "./auth.js";
describe("getTgWebAppDataFromHash", () => {
it("returns empty string when tgWebAppData= not present", () => {
expect(getTgWebAppDataFromHash("foo=bar")).toBe("");
});
it("returns value from tgWebAppData= to next &tgWebApp or end", () => {
expect(getTgWebAppDataFromHash("tgWebAppData=encoded%3Ddata")).toBe(
"encoded=data"
);
});
it("stops at &tgWebApp", () => {
const hash = "tgWebAppData=value&tgWebAppVersion=6";
expect(getTgWebAppDataFromHash(hash)).toBe("value");
});
it("decodes URI component", () => {
expect(getTgWebAppDataFromHash("tgWebAppData=hello%20world")).toBe(
"hello world"
);
});
});
describe("getInitData", () => {
const origLocation = window.location;
const origTelegram = window.Telegram;
afterEach(() => {
window.location = origLocation;
window.Telegram = origTelegram;
});
it("returns initData from Telegram.WebApp when set", () => {
window.Telegram = { WebApp: { initData: "sdk-init-data" } };
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(getInitData()).toBe("sdk-init-data");
});
it("returns data from hash tgWebAppData when SDK empty", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=hash%20data",
search: "",
};
expect(getInitData()).toBe("hash data");
});
it("returns empty string when no source", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(getInitData()).toBe("");
});
});
describe("isLocalhost", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns true for localhost", () => {
delete window.location;
window.location = { ...origLocation, hostname: "localhost" };
expect(isLocalhost()).toBe(true);
});
it("returns true for 127.0.0.1", () => {
delete window.location;
window.location = { ...origLocation, hostname: "127.0.0.1" };
expect(isLocalhost()).toBe(true);
});
it("returns true for empty hostname", () => {
delete window.location;
window.location = { ...origLocation, hostname: "" };
expect(isLocalhost()).toBe(true);
});
it("returns false for other hostnames", () => {
delete window.location;
window.location = { ...origLocation, hostname: "example.com" };
expect(isLocalhost()).toBe(false);
});
});
describe("hasTelegramHashButNoInitData", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns false when hash is empty", () => {
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns true when hash has tgWebAppVersion but no tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(true);
});
it("returns false when hash has both tgWebAppVersion and tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6&tgWebAppData=some%3Ddata",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has tgWebAppData in unencoded form (with & and =)", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=value&tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has no Telegram params", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#other=param",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
});

View File

@@ -2,7 +2,7 @@
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { getCalendarEl, getMonthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
@@ -29,6 +29,9 @@ export function calendarEventsByDate(events) {
return byDate;
}
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
const MAX_DAYS_PER_DUTY = 366;
/**
* Group duties by local date (start_at/end_at are UTC).
* @param {object[]} duties - Duties with start_at, end_at
@@ -39,14 +42,17 @@ export function dutiesByDate(duties) {
duties.forEach((d) => {
const start = new Date(d.start_at);
const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end);
let t = new Date(start);
while (true) {
const key = localDateString(t);
let cursor = new Date(start);
let iterations = 0;
while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = [];
byDate[key].push(d);
if (key === endLocal) break;
t.setDate(t.getDate() + 1);
cursor.setDate(cursor.getDate() + 1);
iterations++;
}
});
return byDate;
@@ -65,6 +71,8 @@ export function renderCalendar(
dutiesByDateMap,
calendarEventsByDateMap
) {
const calendarEl = getCalendarEl();
const monthTitleEl = getMonthTitleEl();
if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(new Date(year, month, 1));
@@ -104,14 +112,8 @@ export function renderCalendar(
start_at: x.start_at,
end_at: x.end_at
}));
cell.setAttribute(
"data-day-duties",
JSON.stringify(dayPayload).replace(/"/g, "&quot;")
);
cell.setAttribute(
"data-day-events",
JSON.stringify(eventSummaries).replace(/"/g, "&quot;")
);
cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
}
const ariaParts = [];

139
webapp/js/calendar.test.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Unit tests for calendar: dutiesByDate (including edge case end_at < start_at),
* calendarEventsByDate.
*/
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js";
import { state } from "./dom.js";
describe("dutiesByDate", () => {
it("groups duty by single local day", () => {
const duties = [
{
full_name: "Alice",
start_at: "2025-02-25T09:00:00Z",
end_at: "2025-02-25T18:00:00Z",
},
];
const byDate = dutiesByDate(duties);
expect(byDate["2025-02-25"]).toHaveLength(1);
expect(byDate["2025-02-25"][0].full_name).toBe("Alice");
});
it("spans duty across multiple days", () => {
const duties = [
{
full_name: "Bob",
start_at: "2025-02-25T00:00:00Z",
end_at: "2025-02-27T23:59:59Z",
},
];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeGreaterThanOrEqual(2);
keys.forEach((k) => expect(byDate[k]).toHaveLength(1));
expect(byDate[keys[0]][0].full_name).toBe("Bob");
});
it("skips duty when end_at < start_at (no infinite loop)", () => {
const duties = [
{
full_name: "Bad",
start_at: "2025-02-28T12:00:00Z",
end_at: "2025-02-25T08:00:00Z",
},
];
const byDate = dutiesByDate(duties);
expect(Object.keys(byDate)).toHaveLength(0);
});
it("does not iterate more than MAX_DAYS_PER_DUTY", () => {
const start = "2025-01-01T00:00:00Z";
const end = "2026-06-01T00:00:00Z";
const duties = [{ full_name: "Long", start_at: start, end_at: end }];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeLessThanOrEqual(367);
});
it("handles empty duties", () => {
expect(dutiesByDate([])).toEqual({});
});
});
describe("calendarEventsByDate", () => {
it("maps events to local date key by UTC date", () => {
const events = [
{ date: "2025-02-25", summary: "Holiday" },
{ date: "2025-02-25", summary: "Meeting" },
{ date: "2025-02-26", summary: "Other" },
];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]);
expect(byDate["2025-02-26"]).toEqual(["Other"]);
});
it("skips events without summary", () => {
const events = [{ date: "2025-02-25", summary: null }];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"] || []).toHaveLength(0);
});
it("handles null or undefined events", () => {
expect(calendarEventsByDate(null)).toEqual({});
expect(calendarEventsByDate(undefined)).toEqual({});
});
});
describe("renderCalendar", () => {
beforeEach(() => {
state.lang = "en";
});
it("renders 42 cells (6 weeks)", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = calendarEl?.querySelectorAll(".day") ?? [];
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = Array.from(calendarEl?.querySelectorAll(".day") ?? []);
const dates = cells.map((c) => c.getAttribute("data-date"));
expect(dates.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d ?? ""))).toBe(true);
});
it("adds today class to cell matching today", () => {
const today = new Date();
renderCalendar(today.getFullYear(), today.getMonth(), {}, {});
const calendarEl = document.getElementById("calendar");
const todayKey =
today.getFullYear() +
"-" +
String(today.getMonth() + 1).padStart(2, "0") +
"-" +
String(today.getDate()).padStart(2, "0");
const todayCell = calendarEl?.querySelector('.day.today[data-date="' + todayKey + '"]');
expect(todayCell).toBeTruthy();
});
it("sets month title from state.lang and given year/month", () => {
state.lang = "en";
renderCalendar(2025, 1, {}, {});
const titleEl = document.getElementById("monthTitle");
expect(titleEl?.textContent).toContain("2025");
expect(titleEl?.textContent).toContain("February");
});
});

150
webapp/js/contactHtml.js Normal file
View File

@@ -0,0 +1,150 @@
/**
* Shared HTML builder for contact links (phone, Telegram) used by day detail,
* current duty, and duty list.
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
/** Phone icon SVG for block layout (inline, same style as dutyList). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Telegram / send icon SVG for block layout. */
const ICON_TELEGRAM =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
/**
* Format Russian phone number for display: 79146522209 -> +7 914 652-22-09.
* Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX).
* Other lengths are returned as-is (digits only).
*
* @param {string} phone - Raw phone string (digits, optional leading 7/8)
* @returns {string} Formatted display string, e.g. "+7 914 652-22-09"
*/
export function formatPhoneDisplay(phone) {
if (phone == null || String(phone).trim() === "") return "";
const digits = String(phone).replace(/\D/g, "");
if (digits.length === 10) {
return "+7 " + digits.slice(0, 3) + " " + digits.slice(3, 6) + "-" + digits.slice(6, 8) + "-" + digits.slice(8);
}
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
const rest = digits.slice(1);
return "+7 " + rest.slice(0, 3) + " " + rest.slice(3, 6) + "-" + rest.slice(6, 8) + "-" + rest.slice(8);
}
return digits;
}
/**
* Build HTML for contact links (phone, Telegram username).
* Validates phone/username, builds tel: and t.me hrefs, wraps in spans/links.
*
* @param {'ru'|'en'} lang - UI language for labels (when showLabels is true)
* @param {string|null|undefined} phone - Phone number
* @param {string|null|undefined} username - Telegram username with or without leading @
* @param {object} options - Rendering options
* @param {string} options.classPrefix - CSS class prefix (e.g. "day-detail-contact", "duty-contact")
* @param {boolean} [options.showLabels=true] - Whether to show "Phone:" / "Telegram:" labels (ignored when layout is "block")
* @param {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ")
* @param {'inline'|'block'} [options.layout='inline'] - "block" = full-width button blocks with SVG icons (for current duty card)
* @returns {string} HTML string or "" if no valid contact
*/
export function buildContactLinksHtml(lang, phone, username, options) {
const { classPrefix, showLabels = true, separator = " ", layout = "inline" } = options || {};
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const displayPhone = formatPhoneDisplay(p);
if (layout === "block") {
const blockClass = classPrefix + "-block " + classPrefix + "-block--phone";
parts.push(
'<a href="' +
safeHref +
'" class="' +
escapeHtml(blockClass) +
'">' +
ICON_PHONE +
"<span>" +
escapeHtml(displayPhone) +
"</span></a>"
);
} else {
const linkHtml =
'<a href="' +
safeHref +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-phone") +
'">' +
escapeHtml(displayPhone) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.phone");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
if (layout === "block") {
const blockClass = classPrefix + "-block " + classPrefix + "-block--telegram";
parts.push(
'<a href="' +
escapeHtml(href) +
'" class="' +
escapeHtml(blockClass) +
'" target="_blank" rel="noopener noreferrer">' +
ICON_TELEGRAM +
"<span>" +
escapeHtml(display) +
"</span></a>"
);
} else {
const linkHtml =
'<a href="' +
escapeHtml(href) +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-username") +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.telegram");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
}
}
if (parts.length === 0) return "";
const rowClass = classPrefix + "-row" + (layout === "block" ? " " + classPrefix + "-row--blocks" : "");
const inner = layout === "block" ? parts.join("") : parts.join(separator);
return '<div class="' + escapeHtml(rowClass) + '">' + inner + "</div>";
}

View File

@@ -0,0 +1,176 @@
/**
* Unit tests for contactHtml (formatPhoneDisplay, buildContactLinksHtml).
*/
import { describe, it, expect } from "vitest";
import { formatPhoneDisplay, buildContactLinksHtml } from "./contactHtml.js";
describe("formatPhoneDisplay", () => {
it("formats 11-digit number starting with 7", () => {
expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09");
expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09");
});
it("formats 11-digit number starting with 8", () => {
expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09");
});
it("formats 10-digit number as Russian", () => {
expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09");
});
it("returns empty string for null or empty", () => {
expect(formatPhoneDisplay(null)).toBe("");
expect(formatPhoneDisplay("")).toBe("");
expect(formatPhoneDisplay(" ")).toBe("");
});
it("strips non-digits before formatting", () => {
expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09");
});
it("returns digits as-is for non-10/11 length", () => {
expect(formatPhoneDisplay("123")).toBe("123");
expect(formatPhoneDisplay("12345678901")).toBe("12345678901");
});
});
describe("buildContactLinksHtml", () => {
const baseOptions = { classPrefix: "test-contact", showLabels: true, separator: " " };
it("returns empty string when phone and username are missing", () => {
expect(buildContactLinksHtml("en", null, null, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", undefined, undefined, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", "", "", baseOptions)).toBe("");
expect(buildContactLinksHtml("en", " ", " ", baseOptions)).toBe("");
});
it("renders phone only with label and tel: link", () => {
const html = buildContactLinksHtml("en", "+79991234567", null, baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("Phone");
expect(html).not.toContain("t.me");
});
it("displays phone formatted for Russian numbers", () => {
const html = buildContactLinksHtml("en", "79146522209", null, baseOptions);
expect(html).toContain("+7 914 652-22-09");
expect(html).toContain('href="tel:79146522209"');
});
it("renders username only with label and t.me link", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("@alice_dev");
expect(html).toContain("Telegram");
expect(html).not.toContain("tel:");
});
it("renders both phone and username with labels", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("tel:");
expect(html).toContain("+79001112233");
expect(html).toContain("+7 900 111-22-33");
expect(html).toContain("t.me");
expect(html).toContain("@bob");
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
});
it("strips leading @ from username and displays with @", () => {
const html = buildContactLinksHtml("en", null, "@alice", baseOptions);
expect(html).toContain("https://t.me/alice");
expect(html).toContain("@alice");
expect(html).not.toContain("@@");
});
it("handles multiple leading @ in username", () => {
const html = buildContactLinksHtml("en", null, "@@@user", baseOptions);
expect(html).toContain("https://t.me/user");
expect(html).toContain("@user");
});
it("escapes special characters in phone href; display uses formatted digits only", () => {
const html = buildContactLinksHtml("en", '+7 999 "1" <2>', null, baseOptions);
expect(html).toContain("&quot;");
expect(html).toContain("&lt;");
expect(html).toContain("tel:");
expect(html).toContain("799912");
expect(html).not.toContain("<2>");
expect(html).not.toContain('"1"');
});
it("uses custom separator when showLabels is false", () => {
const html = buildContactLinksHtml("en", "+7999", "u1", {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
expect(html).toContain(" · ");
expect(html).not.toContain("Phone");
expect(html).not.toContain("Telegram");
expect(html).toContain("duty-contact-row");
expect(html).toContain("duty-contact-link");
});
it("uses Russian labels when lang is ru", () => {
const html = buildContactLinksHtml("ru", "+7999", null, baseOptions);
expect(html).toContain("Телефон");
const htmlTg = buildContactLinksHtml("ru", null, "u", baseOptions);
expect(htmlTg).toContain("Telegram");
});
it("uses default showLabels true and separator space when options omit them", () => {
const html = buildContactLinksHtml("en", "+7999", "u", {
classPrefix: "minimal",
});
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
expect(html).toContain("minimal-row");
expect(html).not.toContain(" · ");
});
describe("layout: block", () => {
it("renders phone as block with icon and formatted number", () => {
const html = buildContactLinksHtml("en", "79146522209", null, {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-row--blocks");
expect(html).toContain("current-duty-contact-block");
expect(html).toContain("current-duty-contact-block--phone");
expect(html).toContain("+7 914 652-22-09");
expect(html).toContain("tel:");
expect(html).toContain("<svg");
expect(html).not.toContain("Phone");
});
it("renders telegram as block with icon and @username", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-block--telegram");
expect(html).toContain("https://t.me/");
expect(html).toContain("@alice_dev");
expect(html).toContain("<svg");
expect(html).not.toContain("Telegram");
});
it("renders both phone and telegram as stacked blocks", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-block--phone");
expect(html).toContain("current-duty-contact-block--telegram");
expect(html).toContain("+7 900 111-22-33");
expect(html).toContain("@bob");
});
});
});

234
webapp/js/currentDuty.js Normal file
View File

@@ -0,0 +1,234 @@
/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
*/
import { getCurrentDutyViewEl, state, getLoadingEl } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { fetchDuties } from "./api.js";
import {
localDateString,
dateKeyToDDMM,
formatHHMM
} from "./dateUtils.js";
/** Empty calendar icon for "no duty" state (outline, stroke). */
const ICON_NO_DUTY =
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
/** @type {(() => void)|null} Callback when user taps "Back to calendar". */
let onBackCallback = null;
/** @type {(() => void)|null} Handler registered with Telegram BackButton.onClick. */
let backButtonHandler = null;
/**
* Compute remaining time until end of shift. Call only when now < end (active duty).
* @param {string|Date} endAt - ISO end time of the shift
* @returns {{ hours: number, minutes: number }}
*/
export function getRemainingTime(endAt) {
const end = new Date(endAt).getTime();
const now = Date.now();
const ms = Math.max(0, end - now);
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
return { hours, minutes };
}
/**
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
* @param {object[]} duties - List of duties with start_at, end_at, event_type
* @returns {object|null}
*/
export function findCurrentDuty(duties) {
const now = Date.now();
const dutyType = (duties || []).filter((d) => d.event_type === "duty");
const candidates = dutyType.length ? dutyType : duties || [];
for (const d of candidates) {
const start = new Date(d.start_at).getTime();
const end = new Date(d.end_at).getTime();
if (start <= now && now < end) return d;
}
return null;
}
/**
* Render the current duty view content (card with duty or no-duty message).
* @param {object|null} duty - Active duty or null
* @param {string} lang
* @returns {string}
*/
export function renderCurrentDutyContent(duty, lang) {
const backLabel = t(lang, "current_duty.back");
const title = t(lang, "current_duty.title");
if (!duty) {
const noDuty = t(lang, "current_duty.no_duty");
return (
'<div class="current-duty-card current-duty-card--no-duty">' +
'<h2 class="current-duty-title">' +
escapeHtml(title) +
"</h2>" +
'<div class="current-duty-no-duty-wrap">' +
'<span class="current-duty-no-duty-icon">' +
ICON_NO_DUTY +
"</span>" +
'<p class="current-duty-no-duty">' +
escapeHtml(noDuty) +
"</p>" +
"</div>" +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(backLabel) +
"</button>" +
"</div>"
);
}
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
const endTime = formatHHMM(duty.end_at);
const shiftStr =
startDDMM +
" " +
startTime +
" — " +
endDDMM +
" " +
endTime;
const shiftLabel = t(lang, "current_duty.shift");
const { hours: remHours, minutes: remMinutes } = getRemainingTime(duty.end_at);
const remainingStr = t(lang, "current_duty.remaining", {
hours: String(remHours),
minutes: String(remMinutes)
});
const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, {
classPrefix: "current-duty-contact",
showLabels: true,
separator: " ",
layout: "block"
});
return (
'<div class="current-duty-card">' +
'<h2 class="current-duty-title">' +
'<span class="current-duty-live-dot"></span> ' +
escapeHtml(title) +
"</h2>" +
'<p class="current-duty-name">' +
escapeHtml(duty.full_name) +
"</p>" +
'<div class="current-duty-shift">' +
escapeHtml(shiftLabel) +
": " +
escapeHtml(shiftStr) +
"</div>" +
'<div class="current-duty-remaining">' +
escapeHtml(remainingStr) +
"</div>" +
contactHtml +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(backLabel) +
"</button>" +
"</div>"
);
}
/**
* Show the current duty view: fetch today's duties, render card or no-duty, show back button.
* Hides calendar/duty list and shows #currentDutyView. Optionally shows Telegram BackButton.
* @param {() => void} onBack - Callback when user taps "Back to calendar"
*/
export async function showCurrentDutyView(onBack) {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (!currentDutyViewEl) return;
onBackCallback = onBack;
currentDutyViewEl.classList.remove("hidden");
if (container) container.setAttribute("data-view", "currentDuty");
if (calendarSticky) calendarSticky.hidden = true;
if (dutyList) dutyList.hidden = true;
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.add("hidden");
const lang = state.lang;
currentDutyViewEl.innerHTML =
'<div class="current-duty-loading">' +
escapeHtml(t(lang, "loading")) +
"</div>";
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
window.Telegram.WebApp.BackButton.show();
const handler = () => {
if (onBackCallback) onBackCallback();
};
backButtonHandler = handler;
window.Telegram.WebApp.BackButton.onClick(handler);
}
const today = new Date();
const from = localDateString(today);
const to = from;
try {
const duties = await fetchDuties(from, to);
const duty = findCurrentDuty(duties);
currentDutyViewEl.innerHTML = renderCurrentDutyContent(duty, lang);
} catch (e) {
currentDutyViewEl.innerHTML =
'<div class="current-duty-card">' +
'<p class="current-duty-error">' +
escapeHtml(e.message || t(lang, "error_generic")) +
"</p>" +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(t(lang, "current_duty.back")) +
"</button>" +
"</div>";
}
currentDutyViewEl.addEventListener("click", handleCurrentDutyClick);
}
/**
* Delegate click for back button.
* @param {MouseEvent} e
*/
function handleCurrentDutyClick(e) {
const btn = e.target && e.target.closest("[data-action='back']");
if (!btn) return;
if (onBackCallback) onBackCallback();
}
/**
* Hide the current duty view and show calendar/duty list again.
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
*/
export function hideCurrentDutyView() {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
if (backButtonHandler) {
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
}
window.Telegram.WebApp.BackButton.hide();
}
if (currentDutyViewEl) {
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
currentDutyViewEl.classList.add("hidden");
currentDutyViewEl.innerHTML = "";
}
onBackCallback = null;
backButtonHandler = null;
if (container) container.removeAttribute("data-view");
if (calendarSticky) calendarSticky.hidden = false;
if (dutyList) dutyList.hidden = false;
}

View File

@@ -0,0 +1,156 @@
/**
* Unit tests for currentDuty (findCurrentDuty, renderCurrentDutyContent, showCurrentDutyView).
*/
import { describe, it, expect, beforeAll, vi } from "vitest";
vi.mock("./api.js", () => ({
fetchDuties: vi.fn().mockResolvedValue([])
}));
import {
findCurrentDuty,
getRemainingTime,
renderCurrentDutyContent
} from "./currentDuty.js";
describe("currentDuty", () => {
beforeAll(() => {
document.body.innerHTML =
'<div id="loading"></div>' +
'<div class="container">' +
'<div id="calendarSticky"></div>' +
'<div id="dutyList"></div>' +
'<div id="currentDutyView" class="current-duty-view hidden"></div>' +
"</div>";
});
describe("getRemainingTime", () => {
it("returns hours and minutes until end from now", () => {
const endAt = "2025-03-02T17:30:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(5);
expect(minutes).toBe(30);
});
it("returns 0 when end is in the past", () => {
const endAt = "2025-03-02T09:00:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(0);
expect(minutes).toBe(0);
});
});
describe("findCurrentDuty", () => {
it("returns duty when now is between start_at and end_at", () => {
const now = new Date();
const start = new Date(now);
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1, 0, 0, 0);
const duties = [
{
event_type: "duty",
full_name: "Иванов",
start_at: start.toISOString(),
end_at: end.toISOString()
}
];
const duty = findCurrentDuty(duties);
expect(duty).not.toBeNull();
expect(duty.full_name).toBe("Иванов");
});
it("returns null when no duty overlaps current time", () => {
const duties = [
{
event_type: "duty",
full_name: "Past",
start_at: "2020-01-01T09:00:00Z",
end_at: "2020-01-01T17:00:00Z"
},
{
event_type: "duty",
full_name: "Future",
start_at: "2030-01-01T09:00:00Z",
end_at: "2030-01-01T17:00:00Z"
}
];
expect(findCurrentDuty(duties)).toBeNull();
});
});
describe("renderCurrentDutyContent", () => {
it("renders no-duty message and back button when duty is null", () => {
const html = renderCurrentDutyContent(null, "en");
expect(html).toContain("current-duty-card");
expect(html).toContain("current-duty-card--no-duty");
expect(html).toContain("Current Duty");
expect(html).toContain("current-duty-no-duty-wrap");
expect(html).toContain("current-duty-no-duty-icon");
expect(html).toContain("current-duty-no-duty");
expect(html).toContain("No one is on duty right now");
expect(html).toContain("Back to calendar");
expect(html).toContain('data-action="back"');
expect(html).not.toContain("current-duty-live-dot");
});
it("renders duty card with name, shift, remaining time, and back button when duty has no contacts", () => {
const duty = {
event_type: "duty",
full_name: "Иванов Иван",
start_at: "2025-03-02T06:00:00.000Z",
end_at: "2025-03-03T06:00:00.000Z"
};
const html = renderCurrentDutyContent(duty, "ru");
expect(html).toContain("current-duty-live-dot");
expect(html).toContain("Текущее дежурство");
expect(html).toContain("Иванов Иван");
expect(html).toContain("Смена");
expect(html).toContain("current-duty-remaining");
expect(html).toMatch(/Осталось:\s*\d+ч\s*\d+мин/);
expect(html).toContain("Назад к календарю");
expect(html).toContain('data-action="back"');
});
it("renders duty card with phone and Telegram links when present", () => {
const duty = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-02T09:00:00",
end_at: "2025-03-02T17:00:00",
phone: "+7 900 123-45-67",
username: "alice_dev"
};
const html = renderCurrentDutyContent(duty, "en");
expect(html).toContain("Alice");
expect(html).toContain("current-duty-remaining");
expect(html).toMatch(/Remaining:\s*\d+h\s*\d+min/);
expect(html).toContain("current-duty-contact-row");
expect(html).toContain("current-duty-contact-row--blocks");
expect(html).toContain("current-duty-contact-block");
expect(html).toContain('href="tel:');
expect(html).toContain("+7 900 123-45-67");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("Back to calendar");
});
});
describe("showCurrentDutyView", () => {
it("hides the global loading element when called", async () => {
vi.resetModules();
const { showCurrentDutyView } = await import("./currentDuty.js");
await showCurrentDutyView(() => {});
const loading = document.getElementById("loading");
expect(loading).not.toBeNull();
expect(loading.classList.contains("hidden")).toBe(true);
});
});
});

View File

@@ -84,16 +84,6 @@ export function dateKeyToDDMM(key) {
return key.slice(8, 10) + "." + key.slice(5, 7);
}
/**
* Format ISO date as HH:MM in local time.
* @param {string} isoStr - ISO date string
* @returns {string} HH:MM
*/
export function formatTimeLocal(isoStr) {
const d = new Date(isoStr);
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}
/**
* Format ISO string as HH:MM (local).
* @param {string} isoStr - ISO date string

230
webapp/js/dateUtils.test.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* Unit tests for dateUtils: localDateString, dutyOverlapsLocalDay,
* dutyOverlapsLocalRange, getMonday, formatHHMM.
*/
import { describe, it, expect } from "vitest";
import {
localDateString,
dutyOverlapsLocalDay,
dutyOverlapsLocalRange,
getMonday,
formatHHMM,
firstDayOfMonth,
lastDayOfMonth,
formatDateKey,
dateKeyToDDMM,
} from "./dateUtils.js";
describe("localDateString", () => {
it("formats date as YYYY-MM-DD", () => {
const d = new Date(2025, 0, 15);
expect(localDateString(d)).toBe("2025-01-15");
});
it("pads month and day with zero", () => {
expect(localDateString(new Date(2025, 0, 5))).toBe("2025-01-05");
expect(localDateString(new Date(2025, 8, 9))).toBe("2025-09-09");
});
it("handles December and year boundary", () => {
expect(localDateString(new Date(2024, 11, 31))).toBe("2024-12-31");
});
});
describe("dutyOverlapsLocalDay", () => {
it("returns true when duty spans the whole day", () => {
const d = {
start_at: "2025-02-25T00:00:00Z",
end_at: "2025-02-25T23:59:59Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns true when duty overlaps part of the day", () => {
const d = {
start_at: "2025-02-25T09:00:00Z",
end_at: "2025-02-25T14:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns true when duty continues from previous day", () => {
const d = {
start_at: "2025-02-24T22:00:00Z",
end_at: "2025-02-25T06:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns false when duty ends before the day", () => {
const d = {
start_at: "2025-02-24T09:00:00Z",
end_at: "2025-02-24T18:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
});
it("returns false when duty starts after the day", () => {
const d = {
start_at: "2025-02-26T09:00:00Z",
end_at: "2025-02-26T18:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
});
});
describe("dutyOverlapsLocalRange", () => {
it("returns true when duty overlaps the range", () => {
const d = {
start_at: "2025-02-24T12:00:00Z",
end_at: "2025-02-26T12:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
});
it("returns true when duty is entirely inside the range", () => {
const d = {
start_at: "2025-02-26T09:00:00Z",
end_at: "2025-02-26T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
});
it("returns false when duty ends before range start", () => {
const d = {
start_at: "2025-02-20T09:00:00Z",
end_at: "2025-02-22T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
});
it("returns false when duty starts after range end", () => {
const d = {
start_at: "2025-03-01T09:00:00Z",
end_at: "2025-03-01T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
});
});
describe("getMonday", () => {
it("returns same day when date is Monday", () => {
const monday = new Date(2025, 0, 6); // 6 Jan 2025 is Monday
const result = getMonday(monday);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(6);
expect(result.getDay()).toBe(1);
});
it("returns previous Monday for Wednesday", () => {
const wed = new Date(2025, 0, 8);
const result = getMonday(wed);
expect(result.getDay()).toBe(1);
expect(result.getDate()).toBe(6);
});
it("returns Monday of same week for Sunday", () => {
const sun = new Date(2025, 0, 12);
const result = getMonday(sun);
expect(result.getDay()).toBe(1);
expect(result.getDate()).toBe(6);
});
});
describe("formatHHMM", () => {
it("formats ISO string as HH:MM in local time", () => {
const s = "2025-02-25T14:30:00Z";
const result = formatHHMM(s);
expect(result).toMatch(/^\d{2}:\d{2}$/);
const d = new Date(s);
const expected = (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes();
expect(result).toBe(expected);
});
it("returns empty string for null", () => {
expect(formatHHMM(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatHHMM(undefined)).toBe("");
});
it("returns empty string for empty string", () => {
expect(formatHHMM("")).toBe("");
});
it("pads hours and minutes with zero", () => {
const s = "2025-02-25T09:05:00Z";
const result = formatHHMM(s);
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("firstDayOfMonth", () => {
it("returns first day of month", () => {
const d = new Date(2025, 5, 15);
const result = firstDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(5);
expect(result.getDate()).toBe(1);
});
it("handles January", () => {
const d = new Date(2025, 0, 31);
const result = firstDayOfMonth(d);
expect(result.getDate()).toBe(1);
expect(result.getMonth()).toBe(0);
});
});
describe("lastDayOfMonth", () => {
it("returns last day of month", () => {
const d = new Date(2025, 0, 15);
const result = lastDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(31);
});
it("returns 28 for non-leap February", () => {
const d = new Date(2023, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(28);
expect(result.getMonth()).toBe(1);
});
it("returns 29 for leap February", () => {
const d = new Date(2024, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(29);
});
});
describe("formatDateKey", () => {
it("formats ISO date string as DD.MM (local time)", () => {
const result = formatDateKey("2025-02-25T00:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
const [day, month] = result.split(".");
expect(Number(day)).toBeGreaterThanOrEqual(1);
expect(Number(day)).toBeLessThanOrEqual(31);
expect(Number(month)).toBeGreaterThanOrEqual(1);
expect(Number(month)).toBeLessThanOrEqual(12);
});
it("returns DD.MM format with zero-padding", () => {
const result = formatDateKey("2025-01-05T12:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
});
});
describe("dateKeyToDDMM", () => {
it("converts YYYY-MM-DD to DD.MM", () => {
expect(dateKeyToDDMM("2025-02-25")).toBe("25.02");
});
it("handles single-digit day and month", () => {
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
});
});

View File

@@ -2,9 +2,10 @@
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
import { getDutyMarkerRows } from "./hints.js";
@@ -28,8 +29,7 @@ let sheetScrollY = 0;
function parseDataAttr(raw) {
if (!raw) return [];
try {
const s = raw.replace(/&quot;/g, '"');
const parsed = JSON.parse(s);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
@@ -73,7 +73,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
);
const rows = hasTimes
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" }));
: dutyList.map((it) => ({
timePrefix: "",
fullName: it.full_name || "",
phone: it.phone,
username: it.username
}));
html +=
'<section class="day-detail-section day-detail-section--duty">' +
@@ -81,12 +86,21 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
escapeHtml(t(lang, "event_type.duty")) +
"</h3><ul class=" +
'"day-detail-list">';
rows.forEach((r) => {
rows.forEach((r, i) => {
const duty = hasTimes ? dutyList[i] : null;
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactLinksHtml(lang, phone, username, {
classPrefix: "day-detail-contact",
showLabels: true,
separator: " "
});
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) +
(contactHtml ? contactHtml : "") +
"</li>";
});
html += "</ul></section>";
@@ -153,6 +167,7 @@ function positionPopover(panel, cellRect) {
const panelRect = panel.getBoundingClientRect();
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
let top = cellRect.bottom + 8;
/* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */
if (top + panelRect.height > vh - margin) {
top = cellRect.top - panelRect.height - 8;
panel.classList.add("day-detail-panel--below");
@@ -210,6 +225,7 @@ function showAsPopover(cellRect) {
const target = e.target instanceof Node ? e.target : null;
if (!target || !panelEl) return;
if (panelEl.contains(target)) return;
const calendarEl = getCalendarEl();
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
hideDayDetail();
};
@@ -344,6 +360,7 @@ function ensurePanelInDom() {
* Bind delegated click/keydown on calendar for .day cells.
*/
export function initDayDetail() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);

View File

@@ -38,4 +38,25 @@ describe("buildDayDetailContent", () => {
const petrovPos = html.indexOf("Петров");
expect(ivanovPos).toBeLessThan(petrovPos);
});
it("includes contact info (phone, username) for duty entries when present", () => {
const dateKey = "2025-03-01";
const duties = [
{
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
phone: "+79991234567",
username: "alice_dev",
},
];
const html = buildDayDetailContent(dateKey, duties, []);
expect(html).toContain("Alice");
expect(html).toContain("day-detail-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
});
});

View File

@@ -1,36 +1,62 @@
/**
* DOM references and shared application state.
* Element refs are resolved lazily via getters so modules can be imported before DOM is ready.
*/
/** @type {HTMLDivElement|null} */
export const calendarEl = document.getElementById("calendar");
/** @returns {HTMLDivElement|null} */
export function getCalendarEl() {
return document.getElementById("calendar");
}
/** @type {HTMLElement|null} */
export const monthTitleEl = document.getElementById("monthTitle");
/** @returns {HTMLElement|null} */
export function getMonthTitleEl() {
return document.getElementById("monthTitle");
}
/** @type {HTMLDivElement|null} */
export const dutyListEl = document.getElementById("dutyList");
/** @returns {HTMLDivElement|null} */
export function getDutyListEl() {
return document.getElementById("dutyList");
}
/** @type {HTMLElement|null} */
export const loadingEl = document.getElementById("loading");
/** @returns {HTMLElement|null} */
export function getLoadingEl() {
return document.getElementById("loading");
}
/** @type {HTMLElement|null} */
export const errorEl = document.getElementById("error");
/** @returns {HTMLElement|null} */
export function getErrorEl() {
return document.getElementById("error");
}
/** @type {HTMLElement|null} */
export const accessDeniedEl = document.getElementById("accessDenied");
/** @returns {HTMLElement|null} */
export function getAccessDeniedEl() {
return document.getElementById("accessDenied");
}
/** @type {HTMLElement|null} */
export const headerEl = document.querySelector(".header");
/** @returns {HTMLElement|null} */
export function getHeaderEl() {
return document.querySelector(".header");
}
/** @type {HTMLElement|null} */
export const weekdaysEl = document.querySelector(".weekdays");
/** @returns {HTMLElement|null} */
export function getWeekdaysEl() {
return document.querySelector(".weekdays");
}
/** @type {HTMLButtonElement|null} */
export const prevBtn = document.getElementById("prevMonth");
/** @returns {HTMLButtonElement|null} */
export function getPrevBtn() {
return document.getElementById("prevMonth");
}
/** @type {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth");
/** @returns {HTMLButtonElement|null} */
export function getNextBtn() {
return document.getElementById("nextMonth");
}
/** @returns {HTMLDivElement|null} */
export function getCurrentDutyViewEl() {
return document.getElementById("currentDutyView");
}
/** Currently viewed month (mutable). */
export const state = {
@@ -41,5 +67,13 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null,
/** @type {'ru'|'en'} */
lang: "ru"
lang: "ru",
/** One-time bind flag for sticky scroll shadow listener. */
stickyScrollBound: false,
/** One-time bind flag for calendar (info button) hint document listeners. */
calendarHintBound: false,
/** One-time bind flag for duty marker hint document listeners. */
dutyMarkerHintBound: false,
/** Whether initData retry after ACCESS_DENIED has been attempted. */
initDataRetried: false
};

View File

@@ -2,20 +2,31 @@
* Duty list (timeline) rendering.
*/
import { dutyListEl, state } from "./dom.js";
import { getDutyListEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
dateKeyToDDMM,
formatTimeLocal,
formatHHMM,
formatDateKey
} from "./dateUtils.js";
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Back/arrow icon SVG for flip button (back to card). */
const ICON_BACK =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
/**
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM HH:MM" or multi-day.
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
* Otherwise returns a plain card without flip wrapper.
* @param {object} d - Duty
* @param {boolean} isCurrent - Whether this is "current" duty
* @returns {string}
@@ -25,8 +36,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
const endLocal = localDateString(new Date(d.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatTimeLocal(d.start_at);
const endTime = formatTimeLocal(d.end_at);
const startTime = formatHHMM(d.start_at);
const endTime = formatHHMM(d.end_at);
let timeStr;
if (startLocal === endLocal) {
timeStr = startDDMM + ", " + startTime + " " + endTime;
@@ -38,15 +49,66 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
);
if (!hasContacts) {
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
extraClass +
'"><span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
"</div></div>"
);
}
const showLabel = t(lang, "contact.show");
const backLabel = t(lang, "contact.back");
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
'<div class="duty-flip-card' +
extraClass +
'"><span class="duty-item-type">' +
'" data-flipped="false">' +
'<div class="duty-flip-inner">' +
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
'</div>' +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(showLabel) +
'">' +
ICON_PHONE +
"</button>" +
"</div>" +
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="name">' +
escapeHtml(d.full_name) +
"</span>" +
contactHtml +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(backLabel) +
'">' +
ICON_BACK +
"</button>" +
"</div>" +
"</div></div>"
);
}
@@ -69,14 +131,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
if (extraClass) itemClass += " " + extraClass;
let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") {
timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) });
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at);
timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr;
} else {
timeOrRange =
formatTimeLocal(d.start_at) + " " + formatTimeLocal(d.end_at);
formatHHMM(d.start_at) + " " + formatHHMM(d.end_at);
}
return (
'<div class="' +
@@ -86,17 +148,33 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
timeOrRange +
escapeHtml(timeOrRange) +
"</div></div>"
);
}
/** Whether the delegated flip-button click listener has been attached to duty list element. */
let flipListenerAttached = false;
/**
* Render duty list (timeline) for current month; scroll to today if visible.
* @param {object[]} duties - Duties (only duty type used for timeline)
*/
export function renderDutyList(duties) {
const dutyListEl = getDutyListEl();
if (!dutyListEl) return;
if (!flipListenerAttached) {
flipListenerAttached = true;
dutyListEl.addEventListener("click", (e) => {
const btn = e.target.closest(".duty-flip-btn");
if (!btn) return;
const card = btn.closest(".duty-flip-card");
if (!card) return;
const flipped = card.getAttribute("data-flipped") === "true";
card.setAttribute("data-flipped", String(!flipped));
});
}
const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline");
@@ -174,8 +252,9 @@ export function renderDutyList(duties) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
const listEl = getDutyListEl();
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
if (currentDutyCard) {
scrollToEl(currentDutyCard);
} else if (todayBlock) {

164
webapp/js/dutyList.test.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering).
*/
import { describe, it, expect, beforeAll, vi, afterEach } from "vitest";
import * as dateUtils from "./dateUtils.js";
import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js";
describe("dutyList", () => {
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><div id="monthTitle"></div>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
describe("dutyTimelineCardHtml", () => {
it("renders duty with full_name and time range (no flip when no contacts)", () => {
const d = {
event_type: "duty",
full_name: "Иванов",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T18:00:00",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Иванов");
expect(html).toContain("duty-item");
expect(html).toContain("duty-timeline-card");
expect(html).not.toContain("duty-flip-card");
expect(html).not.toContain("duty-flip-btn");
});
it("uses flip-card wrapper with front and back when phone or username present", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
phone: "+79991234567",
username: "alice_dev",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Alice");
expect(html).toContain("duty-flip-card");
expect(html).toContain("duty-flip-inner");
expect(html).toContain("duty-flip-front");
expect(html).toContain("duty-flip-back");
expect(html).toContain("duty-flip-btn");
expect(html).toContain('data-flipped="false"');
expect(html).toContain("duty-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
});
it("front face contains name and time; back face contains contact links", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-02T08:00:00",
end_at: "2025-03-02T16:00:00",
phone: "+79001112233",
};
const html = dutyTimelineCardHtml(d, false);
const frontStart = html.indexOf("duty-flip-front");
const backStart = html.indexOf("duty-flip-back");
const frontSection = html.slice(frontStart, backStart);
const backSection = html.slice(backStart);
expect(frontSection).toContain("Bob");
expect(frontSection).toContain("time");
expect(frontSection).not.toContain("duty-contact-row");
expect(backSection).toContain("Bob");
expect(backSection).toContain("duty-contact-row");
expect(backSection).toContain("tel:");
});
it("omits flip wrapper and button when phone and username are missing", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-02T08:00:00",
end_at: "2025-03-02T16:00:00",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Bob");
expect(html).not.toContain("duty-flip-card");
expect(html).not.toContain("duty-flip-btn");
expect(html).not.toContain("duty-contact-row");
});
});
describe("dutyItemHtml", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("escapes timeOrRange so HTML special chars are not rendered raw", () => {
vi.spyOn(dateUtils, "formatHHMM").mockReturnValue("12:00 & 13:00");
vi.spyOn(dateUtils, "formatDateKey").mockReturnValue("01.02.2025");
const d = {
event_type: "duty",
full_name: "Test",
start_at: "2025-03-01T12:00:00",
end_at: "2025-03-01T13:00:00",
};
const html = dutyItemHtml(d, null, false);
expect(html).toContain("&amp;");
expect(html).not.toContain('<div class="time">12:00 & 13:00');
});
it("uses typeLabelOverride when provided", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, "On duty now", false);
expect(html).toContain("On duty now");
expect(html).toContain("Alice");
});
it("shows duty.until when showUntilEnd is true for duty", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, true);
expect(html).toMatch(/until|до/);
expect(html).toMatch(/\d{2}:\d{2}/);
});
it("renders vacation with date range", () => {
vi.spyOn(dateUtils, "formatDateKey")
.mockReturnValueOnce("01.03")
.mockReturnValueOnce("05.03");
const d = {
event_type: "vacation",
full_name: "Charlie",
start_at: "2025-03-01T00:00:00",
end_at: "2025-03-05T23:59:59",
};
const html = dutyItemHtml(d);
expect(html).toContain("01.03 05.03");
expect(html).toContain("duty-item--vacation");
});
it("applies extraClass to container", () => {
const d = {
event_type: "duty",
full_name: "Dana",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, false, "duty-item--current");
expect(html).toContain("duty-item--current");
expect(html).toContain("Dana");
});
});
});

View File

@@ -2,7 +2,7 @@
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
* Remove active class from all duty/unavailable/vacation markers.
*/
export function clearActiveDutyMarker() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl
.querySelectorAll(
@@ -258,148 +259,168 @@ export function clearActiveDutyMarker() {
.forEach((m) => m.classList.remove("calendar-marker-active"));
}
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const HINT_FADE_MS = 150;
/**
* Bind click tooltips for .info-btn (calendar event summaries).
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
* @param {HTMLElement} hintEl - The hint element to dismiss
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
*/
export function bindInfoButtonTooltips() {
let hintEl = document.getElementById("calendarEventHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "calendarEventHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
export function dismissHint(hintEl, opts = {}) {
hintEl.classList.remove("calendar-event-hint--visible");
const id = setTimeout(() => {
hintEl.hidden = true;
document.body.appendChild(hintEl);
}
if (!calendarEl) return;
const lang = state.lang;
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(lang, "hint.events") + "\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
const rect = btn.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.dataset.active = "1";
} else {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
});
if (!document._calendarHintBound) {
document._calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
hintEl.removeAttribute("data-active");
if (opts.clearActive) clearActiveDutyMarker();
if (typeof opts.afterHide === "function") opts.afterHide();
}, HINT_FADE_MS);
return id;
}
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/**
* Get or create the calendar event (info button) hint element.
* @returns {HTMLElement|null}
*/
function getOrCreateCalendarEventHint() {
let el = document.getElementById("calendarEventHint");
if (!el) {
el = document.createElement("div");
el.id = "calendarEventHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Bind hover/click tooltips for duty/unavailable/vacation markers.
* Get or create the duty marker hint element.
* @returns {HTMLElement|null}
*/
export function bindDutyMarkerTooltips() {
let hintEl = document.getElementById("dutyMarkerHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "dutyMarkerHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
hintEl.hidden = true;
document.body.appendChild(hintEl);
function getOrCreateDutyMarkerHint() {
let el = document.getElementById("dutyMarkerHint");
if (!el) {
el = document.createElement("div");
el.id = "dutyMarkerHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Set up event delegation on calendarEl for info button and duty marker tooltips.
* Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render.
*/
export function initHints() {
const calendarEventHint = getOrCreateCalendarEventHint();
const dutyMarkerHint = getOrCreateDutyMarkerHint();
const calendarEl = getCalendarEl();
if (!calendarEl) return;
let hideTimeout = null;
const selector = ".duty-marker, .unavailable-marker, .vacation-marker";
calendarEl.querySelectorAll(selector).forEach((marker) => {
marker.addEventListener("mouseenter", () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
calendarEl.addEventListener("click", (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
if (btn) {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(state.lang, "hint.events") + "\n" + summary;
if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
calendarEventHint.textContent = content;
positionHint(calendarEventHint, btn.getBoundingClientRect());
calendarEventHint.dataset.active = "1";
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
dismissHint(calendarEventHint);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", () => {
if (hintEl.dataset.active) return;
hintEl.classList.remove("calendar-event-hint--visible");
hideTimeout = setTimeout(() => {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", (e) => {
return;
}
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (marker) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
dismissHint(dutyMarkerHint);
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
dutyMarkerHint.innerHTML = html;
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
dutyMarkerHint.dataset.active = "1";
marker.classList.add("calendar-marker-active");
}
});
calendarEl.addEventListener("mouseover", (e) => {
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!marker) return;
const related = e.relatedTarget instanceof Node ? e.relatedTarget : null;
if (related && marker.contains(related)) return;
if (dutyMarkerHideTimeout) {
clearTimeout(dutyMarkerHideTimeout);
dutyMarkerHideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
dutyMarkerHint.innerHTML = html;
} else {
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
});
calendarEl.addEventListener("mouseout", (e) => {
const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!fromMarker) return;
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
if (toMarker) return;
if (dutyMarkerHint.dataset.active) return;
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
afterHide: () => {
dutyMarkerHideTimeout = null;
},
});
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
if (!state.calendarHintBound) {
state.calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
if (calendarEventHint.dataset.active) {
dismissHint(calendarEventHint);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
if (e.key === "Escape" && calendarEventHint.dataset.active) {
dismissHint(calendarEventHint);
}
});
}
if (!state.dutyMarkerHintBound) {
state.dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (dutyMarkerHint.dataset.active) {
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
}

View File

@@ -1,10 +1,11 @@
/**
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
* Also tests dismissHint helper.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { getDutyMarkerRows } from "./hints.js";
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { getDutyMarkerRows, dismissHint } from "./hints.js";
const FROM = "from";
const TO = "until";
@@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => {
expect(rows[2].timePrefix).toContain("15:00");
});
});
describe("dismissHint", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("removes visible class immediately and hides element after delay", () => {
const el = document.createElement("div");
el.classList.add("calendar-event-hint--visible");
el.hidden = false;
el.setAttribute("data-active", "1");
dismissHint(el);
expect(el.classList.contains("calendar-event-hint--visible")).toBe(false);
expect(el.hidden).toBe(false);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(true);
expect(el.hasAttribute("data-active")).toBe(false);
});
it("returns timeout id usable with clearTimeout", () => {
const el = document.createElement("div");
const id = dismissHint(el);
expect(id).toBeDefined();
clearTimeout(id);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(false);
});
it("calls afterHide callback after delay when provided", () => {
const el = document.createElement("div");
let called = false;
dismissHint(el, {
afterHide: () => {
called = true;
},
});
expect(called).toBe(false);
vi.advanceTimersByTime(150);
expect(called).toBe(true);
});
});

View File

@@ -49,7 +49,17 @@ export const MESSAGES = {
"hint.to": "until",
"hint.duty_title": "Duty:",
"hint.events": "Events:",
"day_detail.close": "Close"
"day_detail.close": "Close",
"contact.label": "Contact",
"contact.show": "Contacts",
"contact.back": "Back",
"contact.phone": "Phone",
"contact.telegram": "Telegram",
"current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift",
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar"
},
ru: {
"app.title": "Календарь дежурств",
@@ -94,7 +104,17 @@ export const MESSAGES = {
"hint.to": "до",
"hint.duty_title": "Дежурство:",
"hint.events": "События:",
"day_detail.close": "Закрыть"
"day_detail.close": "Закрыть",
"contact.label": "Контакт",
"contact.show": "Контакты",
"contact.back": "Назад",
"contact.phone": "Телефон",
"contact.telegram": "Telegram",
"current_duty.title": "Текущее дежурство",
"current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю"
}
};

87
webapp/js/i18n.test.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* Unit tests for i18n: getLang, t (fallback, params), monthName.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { getLang, t, monthName, MESSAGES } from "./i18n.js";
describe("getLang", () => {
const origNavigator = globalThis.navigator;
beforeEach(() => {
mockGetInitData.mockReset();
});
it("returns lang from initData user when present", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "en" }))
);
expect(getLang()).toBe("en");
});
it("normalizes ru from initData", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "ru" }))
);
expect(getLang()).toBe("ru");
});
it("falls back to navigator.language when initData empty", () => {
mockGetInitData.mockReturnValue("");
Object.defineProperty(globalThis, "navigator", {
value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] },
configurable: true,
});
expect(getLang()).toBe("en");
});
it("normalizes to en for unknown language code", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" }))
);
expect(getLang()).toBe("en");
});
});
describe("t", () => {
it("returns translation for existing key", () => {
expect(t("en", "app.title")).toBe("Duty Calendar");
expect(t("ru", "app.title")).toBe("Календарь дежурств");
});
it("falls back to en when key missing in lang", () => {
expect(t("ru", "app.title")).toBe("Календарь дежурств");
expect(t("en", "loading")).toBe("Loading…");
});
it("returns key when key missing in both", () => {
expect(t("en", "missing.key")).toBe("missing.key");
expect(t("ru", "unknown")).toBe("unknown");
});
it("replaces params placeholder", () => {
expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
});
it("handles empty params", () => {
expect(t("en", "loading", {})).toBe("Loading…");
});
});
describe("monthName", () => {
it("returns month name for 0-based index", () => {
expect(monthName("en", 0)).toBe("January");
expect(monthName("en", 11)).toBe("December");
expect(monthName("ru", 0)).toBe("Январь");
});
it("returns empty string for out-of-range", () => {
expect(monthName("en", 12)).toBe("");
expect(monthName("en", -1)).toBe("");
});
});

View File

@@ -4,17 +4,16 @@
import { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js";
import { getInitData } from "./auth.js";
import { isLocalhost } from "./auth.js";
import { getInitData, isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import {
state,
accessDeniedEl,
prevBtn,
nextBtn,
loadingEl,
errorEl,
weekdaysEl
getAccessDeniedEl,
getPrevBtn,
getNextBtn,
getLoadingEl,
getErrorEl,
getWeekdaysEl
} from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -24,7 +23,9 @@ import {
renderCalendar
} from "./calendar.js";
import { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js";
import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js";
import {
firstDayOfMonth,
lastDayOfMonth,
@@ -38,15 +39,19 @@ initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
const loadingEl = getLoadingEl();
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
@@ -98,21 +103,33 @@ function requireTelegramOrLocalhost(onAllowed) {
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
let loadMonthAbortController = null;
/**
* Load current month: fetch duties and events, render calendar and duty list.
* Stale requests are cancelled when the user navigates to another month before they complete.
*/
async function loadMonth() {
if (loadMonthAbortController) loadMonthAbortController.abort();
loadMonthAbortController = new AbortController();
const signal = loadMonthAbortController.signal;
hideAccessDenied();
setNavEnabled(false);
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.remove("hidden");
const errorEl = getErrorEl();
if (errorEl) errorEl.hidden = true;
const current = state.current;
const first = firstDayOfMonth(current);
@@ -122,8 +139,8 @@ async function loadMonth() {
const from = localDateString(start);
const to = localDateString(gridEnd);
try {
const dutiesPromise = fetchDuties(from, to);
const eventsPromise = fetchCalendarEvents(from, to);
const dutiesPromise = fetchDuties(from, to, signal);
const eventsPromise = fetchCalendarEvents(from, to, signal);
const duties = await dutiesPromise;
const events = await eventsPromise;
const byDate = dutiesByDate(duties);
@@ -156,15 +173,18 @@ async function loadMonth() {
}, 60000);
}
} catch (e) {
if (e.name === "AbortError") {
return;
}
if (e.message === "ACCESS_DENIED") {
showAccessDenied(e.serverDetail);
setNavEnabled(true);
if (
window.Telegram &&
window.Telegram.WebApp &&
!window._initDataRetried
!state.initDataRetried
) {
window._initDataRetried = true;
state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
}
return;
@@ -173,21 +193,26 @@ async function loadMonth() {
setNavEnabled(true);
return;
}
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
setNavEnabled(true);
}
if (prevBtn) {
prevBtn.addEventListener("click", () => {
const prevBtnEl = getPrevBtn();
if (prevBtnEl) {
prevBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1);
loadMonth();
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
const nextBtnEl = getNextBtn();
if (nextBtnEl) {
nextBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() + 1);
loadMonth();
@@ -204,9 +229,9 @@ if (nextBtn) {
"touchstart",
(e) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
startX = t.clientX;
startY = t.clientY;
const touch = e.changedTouches[0];
startX = touch.clientX;
startY = touch.clientY;
},
{ passive: true }
);
@@ -215,12 +240,15 @@ if (nextBtn) {
(e) => {
if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX;
const deltaY = t.clientY - startY;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (deltaX > SWIPE_THRESHOLD) {
if (prevBtn && prevBtn.disabled) return;
state.current.setMonth(state.current.getMonth() - 1);
@@ -237,8 +265,8 @@ if (nextBtn) {
function bindStickyScrollShadow() {
const stickyEl = document.getElementById("calendarSticky");
if (!stickyEl || document._stickyScrollBound) return;
document._stickyScrollBound = true;
if (!stickyEl || state.stickyScrollBound) return;
state.stickyScrollBound = true;
function updateScrolled() {
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
}
@@ -250,6 +278,19 @@ runWhenReady(() => {
requireTelegramOrLocalhost(() => {
bindStickyScrollShadow();
initDayDetail();
loadMonth();
initHints();
const startParam =
(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe &&
window.Telegram.WebApp.initDataUnsafe.start_param) ||
"";
if (startParam === "duty") {
state.lang = getLang();
showCurrentDutyView(() => {
hideCurrentDutyView();
loadMonth();
});
} else {
loadMonth();
}
});
});

152
webapp/js/theme.test.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Unit tests for theme: getTheme, applyThemeParamsToCss, applyTheme, initTheme.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("theme", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("getTheme", () => {
it("returns Telegram.WebApp.colorScheme when set", async () => {
globalThis.window.Telegram = { WebApp: { colorScheme: "light" } };
vi.spyOn(document.documentElement.style, "getPropertyValue").mockReturnValue("");
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to --tg-color-scheme CSS when TWA has no colorScheme", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue("dark"),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("falls back to matchMedia prefers-color-scheme dark", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("returns light when matchMedia prefers light", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to matchMedia when getComputedStyle throws", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(() => {
throw new Error("getComputedStyle not available");
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
});
describe("applyThemeParamsToCss", () => {
it("does nothing when Telegram.WebApp or themeParams missing", async () => {
globalThis.window.Telegram = undefined;
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).not.toHaveBeenCalled();
});
it("sets --tg-theme-* CSS variables from themeParams", async () => {
globalThis.window.Telegram = {
WebApp: {
themeParams: {
bg_color: "#ffffff",
text_color: "#000000",
hint_color: "#888888",
},
},
};
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).toHaveBeenCalledWith("--tg-theme-bg-color", "#ffffff");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-text-color", "#000000");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-hint-color", "#888888");
});
});
describe("applyTheme", () => {
beforeEach(() => {
document.documentElement.dataset.theme = "";
});
it("sets data-theme on documentElement from getTheme", async () => {
const theme = await import("./theme.js");
vi.spyOn(theme, "getTheme").mockReturnValue("light");
theme.applyTheme();
expect(document.documentElement.dataset.theme).toBe("light");
});
it("calls setBackgroundColor and setHeaderColor when TWA present", async () => {
const setBackgroundColor = vi.fn();
const setHeaderColor = vi.fn();
globalThis.window.Telegram = {
WebApp: {
setBackgroundColor: setBackgroundColor,
setHeaderColor: setHeaderColor,
themeParams: null,
},
};
const { applyTheme } = await import("./theme.js");
applyTheme();
expect(setBackgroundColor).toHaveBeenCalledWith("bg_color");
expect(setHeaderColor).toHaveBeenCalledWith("bg_color");
});
});
describe("initTheme", () => {
it("runs without throwing when TWA present", async () => {
globalThis.window.Telegram = { WebApp: {} };
const { initTheme } = await import("./theme.js");
expect(() => initTheme()).not.toThrow();
});
it("adds matchMedia change listener when no TWA", async () => {
globalThis.window.Telegram = undefined;
const addEventListener = vi.fn();
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener,
removeEventListener: vi.fn(),
});
const { initTheme } = await import("./theme.js");
initTheme();
expect(addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});
});

View File

@@ -4,15 +4,15 @@
import {
state,
calendarEl,
dutyListEl,
loadingEl,
errorEl,
accessDeniedEl,
headerEl,
weekdaysEl,
prevBtn,
nextBtn
getCalendarEl,
getDutyListEl,
getLoadingEl,
getErrorEl,
getAccessDeniedEl,
getHeaderEl,
getWeekdaysEl,
getPrevBtn,
getNextBtn
} from "./dom.js";
import { t } from "./i18n.js";
@@ -21,6 +21,13 @@ import { t } from "./i18n.js";
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
*/
export function showAccessDenied(serverDetail) {
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
const loadingEl = getLoadingEl();
const errorEl = getErrorEl();
const accessDeniedEl = getAccessDeniedEl();
if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true;
if (calendarEl) calendarEl.hidden = true;
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
* Hide access-denied and show calendar/list/header/weekdays.
*/
export function hideAccessDenied() {
const accessDeniedEl = getAccessDeniedEl();
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
if (accessDeniedEl) accessDeniedEl.hidden = true;
if (headerEl) headerEl.hidden = false;
if (weekdaysEl) weekdaysEl.hidden = false;
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
* @param {string} msg - Error text
*/
export function showError(msg) {
const errorEl = getErrorEl();
const loadingEl = getLoadingEl();
if (errorEl) {
errorEl.textContent = msg;
errorEl.hidden = false;
@@ -68,6 +82,8 @@ export function showError(msg) {
* @param {boolean} enabled
*/
export function setNavEnabled(enabled) {
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
}

122
webapp/js/ui.test.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled.
*/
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import {
showAccessDenied,
hideAccessDenied,
showError,
setNavEnabled,
} from "./ui.js";
import { state } from "./dom.js";
describe("ui", () => {
beforeEach(() => {
state.lang = "ru";
const calendar = document.getElementById("calendar");
const dutyList = document.getElementById("dutyList");
const loading = document.getElementById("loading");
const error = document.getElementById("error");
const accessDenied = document.getElementById("accessDenied");
const header = document.querySelector(".header");
const weekdays = document.querySelector(".weekdays");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
if (header) header.hidden = false;
if (weekdays) weekdays.hidden = false;
if (calendar) calendar.hidden = false;
if (dutyList) dutyList.hidden = false;
if (loading) loading.classList.remove("hidden");
if (error) error.hidden = true;
if (accessDenied) accessDenied.hidden = true;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
});
describe("showAccessDenied", () => {
it("hides header, weekdays, calendar, dutyList, loading, error and shows accessDenied", () => {
showAccessDenied();
expect(document.querySelector(".header")?.hidden).toBe(true);
expect(document.querySelector(".weekdays")?.hidden).toBe(true);
expect(document.getElementById("calendar")?.hidden).toBe(true);
expect(document.getElementById("dutyList")?.hidden).toBe(true);
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
expect(document.getElementById("error")?.hidden).toBe(true);
expect(document.getElementById("accessDenied")?.hidden).toBe(false);
});
it("sets accessDenied innerHTML with translated message", () => {
showAccessDenied();
const el = document.getElementById("accessDenied");
expect(el?.innerHTML).toContain("Доступ запрещён");
});
it("appends serverDetail in .access-denied-detail when provided", () => {
showAccessDenied("Custom 403 message");
const el = document.getElementById("accessDenied");
const detail = el?.querySelector(".access-denied-detail");
expect(detail?.textContent).toBe("Custom 403 message");
});
it("does not append detail element when serverDetail is empty string", () => {
showAccessDenied("");
const el = document.getElementById("accessDenied");
expect(el?.querySelector(".access-denied-detail")).toBeNull();
});
});
describe("hideAccessDenied", () => {
it("hides accessDenied and shows header, weekdays, calendar, dutyList", () => {
document.getElementById("accessDenied").hidden = false;
document.querySelector(".header").hidden = true;
document.getElementById("calendar").hidden = true;
hideAccessDenied();
expect(document.getElementById("accessDenied")?.hidden).toBe(true);
expect(document.querySelector(".header")?.hidden).toBe(false);
expect(document.querySelector(".weekdays")?.hidden).toBe(false);
expect(document.getElementById("calendar")?.hidden).toBe(false);
expect(document.getElementById("dutyList")?.hidden).toBe(false);
});
});
describe("showError", () => {
it("sets error text and shows error element", () => {
showError("Network error");
const errorEl = document.getElementById("error");
expect(errorEl?.textContent).toBe("Network error");
expect(errorEl?.hidden).toBe(false);
});
it("adds hidden class to loading element", () => {
document.getElementById("loading").classList.remove("hidden");
showError("Fail");
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
});
});
describe("setNavEnabled", () => {
it("disables prev and next buttons when enabled is false", () => {
setNavEnabled(false);
expect(document.getElementById("prevMonth")?.disabled).toBe(true);
expect(document.getElementById("nextMonth")?.disabled).toBe(true);
});
it("enables prev and next buttons when enabled is true", () => {
document.getElementById("prevMonth").disabled = true;
document.getElementById("nextMonth").disabled = true;
setNavEnabled(true);
expect(document.getElementById("prevMonth")?.disabled).toBe(false);
expect(document.getElementById("nextMonth")?.disabled).toBe(false);
});
});
});

View File

@@ -2,13 +2,19 @@
* Common utilities.
*/
const ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/**
* Escape string for safe use in HTML (text content / attributes).
* @param {string} s - Raw string
* @returns {string} HTML-escaped string
*/
export function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}

42
webapp/js/utils.test.js Normal file
View File

@@ -0,0 +1,42 @@
/**
* Unit tests for escapeHtml edge cases.
*/
import { describe, it, expect } from "vitest";
import { escapeHtml } from "./utils.js";
describe("escapeHtml", () => {
it("escapes ampersand", () => {
expect(escapeHtml("a & b")).toBe("a &amp; b");
});
it("escapes less-than and greater-than", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
});
it("escapes double quote", () => {
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("escapes single quote", () => {
expect(escapeHtml("it's")).toBe("it&#39;s");
});
it("escapes all special chars together", () => {
expect(escapeHtml('&<>"\'')).toBe("&amp;&lt;&gt;&quot;&#39;");
});
it("returns unchanged string when no special chars", () => {
expect(escapeHtml("plain text")).toBe("plain text");
});
it("handles empty string", () => {
expect(escapeHtml("")).toBe("");
});
it("coerces non-string to string", () => {
expect(escapeHtml(123)).toBe("123");
expect(escapeHtml(null)).toBe("null");
expect(escapeHtml(undefined)).toBe("undefined");
});
});

View File

@@ -1,882 +0,0 @@
/* === Variables & themes */
:root {
--bg: #1a1b26;
--surface: #24283b;
--text: #c0caf5;
--muted: #565f89;
--accent: #7aa2f7;
--duty: #9ece6a;
--today: #bb9af7;
--unavailable: #e0af68;
--vacation: #7dcfff;
--error: #f7768e;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
[data-theme="light"] {
--bg: var(--tg-theme-bg-color, #f0f1f3);
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
--text: var(--tg-theme-text-color, #343b58);
--muted: var(--tg-theme-hint-color, #6b7089);
--accent: var(--tg-theme-link-color, #2e7de0);
--duty: #587d0a;
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
--error: #c43b3b;
}
/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
[data-theme="dark"] {
--bg: var(--tg-theme-bg-color, #17212b);
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
--text: var(--tg-theme-text-color, #f5f5f5);
--muted: var(--tg-theme-hint-color, #708499);
--accent: var(--tg-theme-link-color, #6ab3f3);
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
--duty: #5c9b4a;
--unavailable: #b8860b;
--vacation: #5a9bb8;
--error: #e06c75;
}
/* === Layout & base */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
* {
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-top: 0px;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
[data-theme="light"] .container {
border-radius: 12px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header[hidden],
.weekdays[hidden] {
display: none !important;
}
.nav {
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--surface);
color: var(--accent);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav:focus {
outline: none;
}
.nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.nav:active {
transform: scale(0.95);
}
.nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.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-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding-bottom: 12px;
margin-bottom: 4px;
touch-action: pan-y;
transition: box-shadow var(--transition-fast) ease-out;
}
/* === Calendar grid & day cells */
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 16px;
}
.day {
position: relative;
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);
min-width: 0;
min-height: 0;
overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.day.other-month {
opacity: 0.4;
}
.day.today {
background: var(--today);
color: var(--bg);
}
.day.has-duty .num {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
.day.today.holiday {
background: var(--today);
color: var(--bg);
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
}
.day {
cursor: pointer;
}
.day:focus {
outline: none;
}
.day:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day:active {
transform: scale(0.98);
}
.day-indicator {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
margin-top: 6px;
}
.day-indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* On "today" cell: dots darkened for contrast on --today background */
.day.today .day-indicator-dot.duty {
background: color-mix(in srgb, var(--duty) 65%, var(--bg));
}
.day.today .day-indicator-dot.unavailable {
background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
}
.day.today .day-indicator-dot.vacation {
background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
}
.day.today .day-indicator-dot.events {
background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}
/* === Day detail panel (popover / bottom sheet) */
/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
body.day-detail-sheet-open {
position: fixed;
left: 0;
right: 0;
overflow: hidden;
}
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal) ease-out;
}
.day-detail-overlay.day-detail-overlay--visible {
opacity: 1;
pointer-events: auto;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
/* Комфортный отступ снизу: safe area + дополнительное поле */
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform var(--transition-normal) var(--ease-out);
}
.day-detail-panel--sheet.day-detail-panel--open {
transform: translateY(0);
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.day-detail-close:focus {
outline: none;
}
.day-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.day-markers {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 2px;
align-items: center;
margin-top: 2px;
min-width: 0;
}
/* === Hints (tooltips) */
.calendar-event-hint {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(98vw, 900px);
min-width: 0;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
overflow: visible;
transform: translateY(-100%);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.calendar-event-hint:not(.calendar-event-hint--visible) {
opacity: 0;
}
.calendar-event-hint.calendar-event-hint--visible {
opacity: 1;
}
.calendar-event-hint.below {
transform: none;
}
.calendar-event-hint-title {
margin-bottom: 4px;
font-weight: 600;
}
.calendar-event-hint-rows {
display: table;
width: min-content;
table-layout: auto;
border-collapse: separate;
border-spacing: 0 2px;
}
.calendar-event-hint-row {
display: table-row;
white-space: nowrap;
}
.calendar-event-hint-row .calendar-event-hint-time {
display: table-cell;
white-space: nowrap;
width: 1%;
vertical-align: top;
text-align: right;
padding-right: 0.15em;
}
.calendar-event-hint-row .calendar-event-hint-sep {
display: table-cell;
width: 1em;
vertical-align: top;
padding-right: 0.1em;
}
.calendar-event-hint-row .calendar-event-hint-name {
display: table-cell;
white-space: nowrap !important;
}
/* === Markers (duty / unavailable / vacation) */
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: box-shadow var(--transition-fast) ease-out;
}
.duty-marker {
color: var(--duty);
background: rgba(158, 206, 106, 0.25);
}
.unavailable-marker {
color: var(--unavailable);
background: color-mix(in srgb, var(--unavailable) 25%, transparent);
}
.vacation-marker {
color: var(--vacation);
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}
/* === Duty list & timeline */
.duty-list {
font-size: 0.9rem;
}
.duty-list h2 {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 8px 0;
}
.duty-list-day {
margin-bottom: 16px;
}
.duty-list-day--today .duty-list-day-title {
color: var(--today);
font-weight: 700;
}
.duty-list-day--today .duty-list-day-title::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
background: var(--today);
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
/* Timeline: dates | track (line + dot) | cards */
.duty-list.duty-timeline {
position: relative;
}
.duty-list.duty-timeline::before {
content: "";
position: absolute;
left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
top: 0;
bottom: 0;
width: 2px;
background: var(--muted);
pointer-events: none;
}
.duty-timeline-day {
margin-bottom: 0;
}
.duty-timeline-day--today {
scroll-margin-top: 200px;
}
.duty-timeline-row {
display: grid;
grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
gap: 0 4px;
align-items: start;
margin-bottom: 8px;
min-height: 1px;
}
.duty-timeline-date {
position: relative;
font-size: 0.8rem;
color: var(--muted);
padding-top: 10px;
padding-bottom: 10px;
flex-shrink: 0;
overflow: visible;
}
.duty-timeline-date::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
color-mix(in srgb, var(--muted) 40%, transparent) 0%,
color-mix(in srgb, var(--muted) 40%, transparent) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
.duty-timeline-day--today .duty-timeline-date {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: 4px;
color: var(--today);
font-weight: 600;
}
.duty-timeline-day--today .duty-timeline-date::before,
.duty-timeline-day--today .duty-timeline-date::after {
display: none;
}
.duty-timeline-date-label,
.duty-timeline-date-day {
display: block;
line-height: 1.25;
}
.duty-timeline-date-day {
align-self: flex-start;
text-align: left;
padding-left: 0;
margin-left: 0;
}
.duty-timeline-date-dot {
display: block;
width: 100%;
height: 8px;
min-height: 8px;
position: relative;
flex-shrink: 0;
}
.duty-timeline-date-dot::before {
content: "";
position: absolute;
left: 0;
top: 50%;
margin-top: -1px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: color-mix(in srgb, var(--today) 45%, transparent);
}
.duty-timeline-date-dot::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
top: 50%;
margin-top: -3px;
width: 2px;
height: 6px;
background: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
color: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
color: var(--muted);
font-weight: 400;
font-size: 0.75rem;
}
.duty-timeline-track {
min-width: 0;
}
.duty-timeline-card-wrap {
min-width: 0;
}
.duty-timeline-card.duty-item,
.duty-list .duty-item {
display: grid;
grid-template-columns: 1fr;
gap: 2px 0;
align-items: baseline;
padding: 8px 10px;
margin-bottom: 0;
border-radius: 8px;
background: var(--surface);
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: var(--unavailable);
}
.duty-item--vacation {
border-left-color: var(--vacation);
}
[data-theme="dark"] .duty-marker {
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.duty-item .duty-item-type {
grid-column: 1;
grid-row: 1;
font-size: 0.75rem;
color: var(--muted);
}
.duty-item .name {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
font-weight: 600;
}
.duty-item .time {
grid-column: 1;
grid-row: 2;
align-self: start;
font-size: 0.8rem;
color: var(--muted);
}
.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
.duty-item--current {
border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface));
}
/* === Loading / error / access denied */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
color: var(--muted);
text-align: center;
}
.loading__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading__spinner {
animation: none;
border-top-color: var(--accent);
border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.loading, .error {
text-align: center;
padding: 12px;
color: var(--muted);
}
.error,
.access-denied {
transition: opacity 0.2s ease-out;
}
.error {
color: var(--error);
}
.error[hidden], .loading.hidden {
display: none !important;
}
.access-denied {
text-align: center;
padding: 24px 12px;
color: var(--muted);
}
.access-denied p {
margin: 0 0 8px 0;
}
.access-denied p:first-child {
color: var(--error);
font-weight: 600;
}
.access-denied .access-denied-detail {
margin-top: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.access-denied[hidden] {
display: none !important;
}