9 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
b906bfa777 refactor: improve code formatting and readability in group duty pin command and tests
All checks were successful
CI / lint-and-test (push) Successful in 25s
Docker Build and Release / build-and-push (push) Successful in 54s
Docker Build and Release / release (push) Successful in 8s
- Enhanced the `pin_duty_cmd` function by improving code formatting for better readability, ensuring consistent style across the codebase.
- Updated unit tests for `pin_duty_cmd` to follow the same formatting improvements, enhancing clarity and maintainability.
- No functional changes were made; the focus was solely on code style and organization.
2026-02-25 14:58:03 +03:00
55 changed files with 5049 additions and 1277 deletions

View File

@@ -39,3 +39,14 @@ jobs:
- name: Security check with Bandit - name: Security check with Bandit
run: | run: |
bandit -r duty_teller -ll 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 from datetime import date, timedelta
import duty_teller.config as config import duty_teller.config as config
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Depends, FastAPI, Request from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response 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( @app.get(
"/api/duties", "/api/duties",
response_model=list[DutyWithUser], response_model=list[DutyWithUser],
@@ -126,7 +144,9 @@ def get_team_calendar_ical(
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d") to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date) all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [ 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_bytes = build_team_ics(duties_duty_only)
ics_calendar_cache.set(cache_key, ics_bytes) 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. to_date: End date YYYY-MM-DD.
Returns: 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) rows = get_duties(session, from_date=from_date, to_date=to_date)
return [ return [
@@ -203,6 +203,8 @@ def fetch_duties_response(
event_type=( event_type=(
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty" 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 bot_token: str
database_url: str database_url: str
bot_username: str
mini_app_base_url: str mini_app_base_url: str
http_host: str http_host: str
http_port: int http_port: int
@@ -93,9 +94,11 @@ class Settings:
) )
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip() raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
http_host = raw_host if raw_host else "127.0.0.1" http_host = raw_host if raw_host else "127.0.0.1"
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
return cls( return cls(
bot_token=bot_token, bot_token=bot_token,
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"), 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("/"), mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
http_host=http_host, http_host=http_host,
http_port=int(os.getenv("HTTP_PORT", "8080")), http_port=int(os.getenv("HTTP_PORT", "8080")),
@@ -123,6 +126,7 @@ _settings = Settings.from_env()
BOT_TOKEN = _settings.bot_token BOT_TOKEN = _settings.bot_token
DATABASE_URL = _settings.database_url DATABASE_URL = _settings.database_url
BOT_USERNAME = _settings.bot_username
MINI_APP_BASE_URL = _settings.mini_app_base_url MINI_APP_BASE_URL = _settings.mini_app_base_url
HTTP_HOST = _settings.http_host HTTP_HOST = _settings.http_host
HTTP_PORT = _settings.http_port HTTP_PORT = _settings.http_port

View File

@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True) chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
message_id: Mapped[int] = mapped_column(Integer, nullable=False) 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, User,
Duty, Duty,
GroupDutyPin, GroupDutyPin,
TrustedGroup,
CalendarSubscriptionToken, CalendarSubscriptionToken,
Role, Role,
) )
@@ -316,8 +317,8 @@ def get_duties(
session: Session, session: Session,
from_date: str, from_date: str,
to_date: str, to_date: str,
) -> list[tuple[Duty, str]]: ) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties overlapping the given date range with user full_name. """Return duties overlapping the given date range with user full_name, phone, username.
Args: Args:
session: DB session. session: DB session.
@@ -325,11 +326,11 @@ def get_duties(
to_date: End date YYYY-MM-DD. to_date: End date YYYY-MM-DD.
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name, phone, username) tuples.
""" """
to_date_next = to_date_exclusive_iso(to_date) to_date_next = to_date_exclusive_iso(to_date)
q = ( q = (
session.query(Duty, User.full_name) session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id) .join(User, Duty.user_id == User.id)
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date) .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
) )
@@ -342,7 +343,7 @@ def get_duties_for_user(
from_date: str, from_date: str,
to_date: str, to_date: str,
event_types: list[str] | None = None, 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. """Return duties for one user overlapping the date range.
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation"). 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. event_types: If not None, only return duties whose event_type is in this list.
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name, phone, username) tuples.
""" """
to_date_next = to_date_exclusive_iso(to_date) to_date_next = to_date_exclusive_iso(to_date)
filters = [ filters = [
@@ -367,7 +368,7 @@ def get_duties_for_user(
if event_types is not None: if event_types is not None:
filters.append(Duty.event_type.in_(event_types)) filters.append(Duty.event_type.in_(event_types))
q = ( q = (
session.query(Duty, User.full_name) session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id) .join(User, Duty.user_id == User.id)
.filter(*filters) .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] 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( def set_user_phone(
session: Session, telegram_user_id: int, phone: str | None session: Session, telegram_user_id: int, phone: str | None
) -> User | None: ) -> User | None:

View File

@@ -55,13 +55,16 @@ class DutyInDb(DutyBase):
class DutyWithUser(DutyInDb): 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. 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 full_name: str
event_type: Literal["duty", "unavailable", "vacation"] = "duty" event_type: Literal["duty", "unavailable", "vacation"] = "duty"
phone: str | None = None
username: str | None = None
model_config = ConfigDict(from_attributes=True) 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.group_duty_pin_handler)
app.add_handler(group_duty_pin.pin_duty_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.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) 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): if await is_admin_async(update.effective_user.id):
lines.append(t(lang, "help.import_schedule")) lines.append(t(lang, "help.import_schedule"))
lines.append(t(lang, "help.set_role")) 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)) await update.message.reply_text("\n".join(lines))

View File

@@ -6,13 +6,14 @@ from datetime import datetime, timezone
from typing import Literal from typing import Literal
import duty_teller.config as config import duty_teller.config as config
from telegram import Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatMemberStatus from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
from duty_teller.db.session import session_scope from duty_teller.db.session import session_scope
from duty_teller.i18n import get_lang, t 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 ( from duty_teller.services.group_duty_pin_service import (
get_duty_message_text, get_duty_message_text,
get_message_id, get_message_id,
@@ -21,6 +22,9 @@ from duty_teller.services.group_duty_pin_service import (
save_pin, save_pin,
delete_pin, delete_pin,
get_all_pin_chat_ids, get_all_pin_chat_ids,
is_group_trusted,
trust_group,
untrust_group,
) )
logger = logging.getLogger(__name__) 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) 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( async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None application, chat_id: int, when_utc: datetime | None
) -> None: ) -> None:
@@ -102,17 +154,40 @@ async def _schedule_next_update(
async def _refresh_pin_for_chat( async def _refresh_pin_for_chat(
context: ContextTypes.DEFAULT_TYPE, chat_id: int context: ContextTypes.DEFAULT_TYPE, chat_id: int
) -> Literal["updated", "no_message", "failed"]: ) -> Literal["updated", "no_message", "failed", "untrusted"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id. """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). 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: Returns:
"updated" if the message was sent, pinned and saved successfully; "updated" if the message was sent, pinned and saved successfully;
"no_message" if there is no pin record for this chat; "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() 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( message_id, text, next_end = await loop.run_in_executor(
None, None,
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE), 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: if message_id is None:
logger.info("No pin record for chat_id=%s, skipping update", chat_id) logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return "no_message" return "no_message"
old_message_id = message_id
try: 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: except (BadRequest, Forbidden) as e:
logger.warning( logger.warning(
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e "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) await _schedule_next_update(context.application, chat_id, next_end)
return "failed" return "failed"
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id) 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) await _schedule_next_update(context.application, chat_id, next_end)
return "updated" return "updated"
@@ -175,12 +265,27 @@ async def my_chat_member_handler(
ChatMemberStatus.BANNED, ChatMemberStatus.BANNED,
): ):
loop = asyncio.get_running_loop() 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) lang = get_lang(update.effective_user)
text = await loop.run_in_executor( text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang) None, lambda: _get_duty_message_text_sync(lang)
) )
try: 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: except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e) logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return return
@@ -244,13 +349,21 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
return return
chat_id = chat.id chat_id = chat.id
loop = asyncio.get_running_loop() 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) message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None: if message_id is None:
text = await loop.run_in_executor( text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang) None, lambda: _get_duty_message_text_sync(lang)
) )
try: 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: except (BadRequest, Forbidden) as e:
logger.warning( logger.warning(
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e "Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
@@ -266,14 +379,18 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
) )
pinned = True pinned = True
except (BadRequest, Forbidden) as e: except (BadRequest, Forbidden) as e:
logger.warning("Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e) logger.warning(
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id) 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) next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end) await _schedule_next_update(context.application, chat_id, next_end)
if pinned: if pinned:
await update.message.reply_text(t(lang, "pin_duty.pinned")) await update.message.reply_text(t(lang, "pin_duty.pinned"))
else: else:
await update.message.reply_text(t(lang, "pin_duty.could_not_pin_make_admin")) await update.message.reply_text(
t(lang, "pin_duty.could_not_pin_make_admin")
)
return return
try: try:
await context.bot.pin_chat_message( await context.bot.pin_chat_message(
@@ -297,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")) await update.message.reply_text(t(lang, "refresh_pin.group_only"))
return return
chat_id = chat.id 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) result = await _refresh_pin_for_chat(context, chat_id)
await update.message.reply_text(t(lang, f"refresh_pin.{result}")) 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( group_duty_pin_handler = ChatMemberHandler(
my_chat_member_handler, my_chat_member_handler,
ChatMemberHandler.MY_CHAT_MEMBER, ChatMemberHandler.MY_CHAT_MEMBER,
) )
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd) pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_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.no_message": "There is no pinned duty message to refresh in this chat.",
"refresh_pin.updated": "Pinned duty message updated.", "refresh_pin.updated": "Pinned duty message updated.",
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).", "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.private_only": "The /calendar_link command is only available in private chat.",
"calendar_link.access_denied": "Access denied.", "calendar_link.access_denied": "Access denied.",
"calendar_link.success": ( "calendar_link.success": (
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"administrator with «Pin messages» permission, then send /pin_duty in the " "administrator with «Pin messages» permission, then send /pin_duty in the "
"chat — the current message will be pinned." "chat — the current message will be pinned."
), ),
"pin_duty.view_contacts": "View contacts",
"duty.no_duty": "No duty at the moment.", "duty.no_duty": "No duty at the moment.",
"duty.label": "Duty:", "duty.label": "Duty:",
"import.admin_only": "Access for administrators only.", "import.admin_only": "Access for administrators only.",
@@ -74,6 +88,13 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Access denied", "api.access_denied": "Access denied",
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format", "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", "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": { "ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.", "start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -92,6 +113,19 @@ MESSAGES: dict[str, dict[str, str]] = {
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.", "refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.", "refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).", "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.private_only": "Команда /calendar_link доступна только в личке.",
"calendar_link.access_denied": "Доступ запрещён.", "calendar_link.access_denied": "Доступ запрещён.",
"calendar_link.success": ( "calendar_link.success": (
@@ -116,6 +150,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. " "pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), " "Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.", "затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
"pin_duty.view_contacts": "Контакты",
"duty.no_duty": "Сейчас дежурства нет.", "duty.no_duty": "Сейчас дежурства нет.",
"duty.label": "Дежурство:", "duty.label": "Дежурство:",
"import.admin_only": "Доступ только для администраторов.", "import.admin_only": "Доступ только для администраторов.",
@@ -136,5 +171,12 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Доступ запрещён", "api.access_denied": "Доступ запрещён",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD", "dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to", "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.handlers import group_duty_pin, register_handlers
from duty_teller.utils.http_client import safe_urlopen 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( logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO, level=logging.INFO,
@@ -69,6 +78,7 @@ def main() -> None:
ApplicationBuilder() ApplicationBuilder()
.token(config.BOT_TOKEN) .token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs) .post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build() .build()
) )
register_handlers(app) register_handlers(app)

View File

@@ -13,6 +13,9 @@ from duty_teller.db.repository import (
save_group_duty_pin, save_group_duty_pin,
delete_group_duty_pin, delete_group_duty_pin,
get_all_group_duty_pin_chat_ids, 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.i18n import t
from duty_teller.utils.dates import parse_utc_iso 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"🕐 {label} {time_range}",
f"👤 {user.full_name}", f"👤 {user.full_name}",
] ]
if user.phone:
lines.append(f"📞 {user.phone}")
if user.username:
lines.append(f"@{user.username}")
return "\n".join(lines) return "\n".join(lines)
@@ -164,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
List of chat ids. List of chat ids.
""" """
return get_all_group_duty_pin_chat_ids(session) 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.status_code == 400
assert exc_info.value.detail == "From after to message" assert exc_info.value.detail == "From after to message"
mock_t.assert_called_with("ru", "dates.from_after_to") 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): 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): with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
r = client.get( r = client.get(
@@ -266,6 +267,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert len(data) == 1 assert len(data) == 1
assert data[0]["event_type"] == "duty" assert data[0]["event_type"] == "duty"
assert data[0]["full_name"] == "User A" 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): 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", end_at="2026-06-16T18:00:00Z",
event_type="vacation", 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" mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
token = "y" * 43 token = "y" * 43
@@ -371,7 +378,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
end_at="2026-06-15T18:00:00Z", end_at="2026-06-15T18:00:00Z",
event_type="duty", 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 = ( mock_build_ics.return_value = (
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR" 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", end_at="2026-06-15T18:00:00Z",
event_type="duty", 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" mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
token = "z" * 43 token = "z" * 43

View File

@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
assert result == "No duty" assert result == "No duty"
def test_with_duty_and_user_returns_formatted(self): 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( duty = SimpleNamespace(
start_at="2025-01-15T09:00:00Z", start_at="2025-01-15T09:00:00Z",
end_at="2025-01-15T18: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 "" mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru") result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
assert "Иван Иванов" in result assert "Иван Иванов" in result
assert "+79001234567" in result or "79001234567" in result
assert "@ivan" in result
assert "Duty" 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: class TestGetDutyMessageText:

View File

@@ -11,6 +11,13 @@ import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod 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: 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.""" """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 --- # --- _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 @pytest.mark.asyncio
async def test_schedule_next_update_job_queue_none_returns_early(): async def test_schedule_next_update_job_queue_none_returns_early():
"""_schedule_next_update: job_queue is None -> log and return, no run_once.""" """_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 @pytest.mark.asyncio
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next(): 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 = MagicMock()
new_msg.message_id = 999 new_msg.message_id = 999
context = MagicMock() 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.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock() context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock() context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock() context.application = MagicMock()
context.application.job_queue = MagicMock() context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[]) context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock() context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True): with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None) with patch.object(
): mod,
with patch.object(mod, "_schedule_next_update", AsyncMock()): "_sync_get_pin_refresh_data",
with patch.object(mod, "_sync_save_pin") as mock_save: return_value=(1, "Current duty", None),
await mod.update_group_pin(context) ):
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty") 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.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=123, message_id=999, disable_notification=False chat_id=123, message_id=999, disable_notification=False
) )
mock_save.assert_called_once_with(123, 999) 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 @pytest.mark.asyncio
@@ -166,10 +243,11 @@ async def test_update_group_pin_no_message_id_skips():
context.bot = MagicMock() context.bot = MagicMock()
context.bot.send_message = AsyncMock() context.bot.send_message = AsyncMock()
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None) with patch.object(
): mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
await mod.update_group_pin(context) ):
await mod.update_group_pin(context)
context.bot.send_message.assert_not_called() 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.bot.pin_chat_message = AsyncMock()
context.application = MagicMock() context.application = MagicMock()
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None) 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, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
context.bot.unpin_chat_message.assert_not_called() context.bot.unpin_chat_message.assert_not_called()
context.bot.pin_chat_message.assert_not_called() context.bot.pin_chat_message.assert_not_called()
mock_schedule.assert_called_once_with(context.application, 111, None) 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() context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True): with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object( with patch.object(
mod, "_schedule_next_update", AsyncMock() mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
) as mock_schedule: ):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(
with patch.object(mod, "logger") as mock_logger: mod, "_schedule_next_update", AsyncMock()
await mod.update_group_pin(context) ) as mock_schedule:
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text") 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_save.assert_not_called()
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0] 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 @pytest.mark.asyncio
async def test_update_group_pin_duty_pin_notify_false_pins_silent(): 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 = MagicMock()
new_msg.message_id = 777 new_msg.message_id = 777
context = MagicMock() 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.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock() context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock() context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock() context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", False): with patch.object(config, "DUTY_PIN_NOTIFY", False):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object( with patch.object(
mod, "_schedule_next_update", AsyncMock() mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
) as mock_schedule: ):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(
await mod.update_group_pin(context) mod, "_schedule_next_update", AsyncMock()
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text") ) 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.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=333, message_id=777, disable_notification=True chat_id=333, message_id=777, disable_notification=True
) )
mock_save.assert_called_once_with(333, 777) 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) 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() context.bot.pin_chat_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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.object(mod, "_sync_is_trusted", return_value=True):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: with patch.object(mod, "_sync_get_message_id", return_value=5):
mock_t.return_value = "Pinned" with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
await mod.pin_duty_cmd(update, context) mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=5, disable_notification=True chat_id=100, message_id=5, disable_notification=True
) )
update.message.reply_text.assert_called_once_with("Pinned") 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 @pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned(): 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.""" """pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
@@ -327,15 +441,26 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep
context.application.job_queue.run_once = MagicMock() context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"): with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): mod, "_get_duty_message_text_sync", return_value="Duty text"
with patch.object(mod, "_schedule_next_update", AsyncMock()): ):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: with patch.object(mod, "_sync_save_pin") as mock_save:
mock_t.return_value = "Pinned" with patch.object(
await mod.pin_duty_cmd(update, context) mod, "_get_next_shift_end_sync", return_value=None
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") ):
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( context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=42, disable_notification=True chat_id=100, message_id=42, disable_notification=True
) )
@@ -360,13 +485,20 @@ async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
context.application = MagicMock() context.application = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"): with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule: mod, "_get_duty_message_text_sync", return_value="Duty"
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: ):
mock_t.return_value = "Failed" with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.pin_duty_cmd(update, context) 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") update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "pin_duty.failed") mock_t.assert_called_with("en", "pin_duty.failed")
mock_save.assert_not_called() mock_save.assert_not_called()
@@ -395,15 +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() context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"): with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): mod, "_get_duty_message_text_sync", return_value="Duty"
with patch.object(mod, "_schedule_next_update", AsyncMock()): ):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: with patch.object(mod, "_sync_save_pin") as mock_save:
mock_t.return_value = "Make me admin to pin" with patch.object(
await mod.pin_duty_cmd(update, context) mod, "_get_next_shift_end_sync", return_value=None
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty") ):
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) mock_save.assert_called_once_with(100, 43)
update.message.reply_text.assert_called_once_with("Make me admin to pin") 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") mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
@@ -426,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("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5): with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: with patch.object(mod, "_sync_get_message_id", return_value=5):
mock_t.return_value = "Failed to pin" with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
await mod.pin_duty_cmd(update, context) 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") update.message.reply_text.assert_called_once_with("Failed to pin")
mock_t.assert_called_with("en", "pin_duty.failed") mock_t.assert_called_with("en", "pin_duty.failed")
@@ -470,12 +614,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
context = MagicMock() context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated") 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" with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
await mod.refresh_pin_cmd(update, context) mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Updated") update.message.reply_text.assert_called_once_with("Updated")
mock_t.assert_called_with("en", "refresh_pin.updated") mock_t.assert_called_with("en", "refresh_pin.updated")
@@ -493,12 +638,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
context = MagicMock() context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message") 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" with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
await mod.refresh_pin_cmd(update, context) mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message") update.message.reply_text.assert_called_once_with("No message")
mock_t.assert_called_with("en", "refresh_pin.no_message") mock_t.assert_called_with("en", "refresh_pin.no_message")
@@ -516,16 +662,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
context = MagicMock() context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object( with patch.object(mod, "_sync_is_trusted", return_value=True):
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed") 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" with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
await mod.refresh_pin_cmd(update, context) mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed") update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "refresh_pin.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 --- # --- my_chat_member_handler ---
@@ -574,12 +746,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
context.application.job_queue.run_once = MagicMock() context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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_is_trusted", return_value=True):
with patch.object(mod, "_sync_save_pin"): with patch.object(
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): mod, "_get_duty_message_text_sync", return_value="Duty text"
with patch.object(mod, "_schedule_next_update", AsyncMock()): ):
await mod.my_chat_member_handler(update, context) with patch.object(mod, "_sync_save_pin"):
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text") 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( context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True chat_id=200, message_id=42, disable_notification=True
) )
@@ -605,13 +845,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
context.application.job_queue.run_once = MagicMock() context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"): 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_is_trusted", return_value=True):
with patch.object(mod, "_sync_save_pin"): with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_schedule_next_update", AsyncMock()): with patch.object(
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t: mod, "_get_next_shift_end_sync", return_value=None
mock_t.return_value = "Make me admin to pin" ):
await mod.my_chat_member_handler(update, context) 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 assert context.bot.send_message.call_count >= 2
pin_hint_calls = [ pin_hint_calls = [
c c
@@ -662,3 +907,196 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
assert mock_schedule.call_count == 2 assert mock_schedule.call_count == 2
mock_schedule.assert_any_call(application, 10, None) mock_schedule.assert_any_call(application, 10, None)
mock_schedule.assert_any_call(application, 20, 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-16T06:00:00Z" in starts
assert "2026-02-17T06:00:00Z" in starts assert "2026-02-17T06:00:00Z" in starts
assert "2026-02-18T06: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" 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> <!DOCTYPE html>
<html lang="ru"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"> <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"> <link rel="icon" href="favicon.png" type="image/png">
<title>Календарь дежурств</title> <title></title>
<link rel="stylesheet" href="style.css"> <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> </head>
<body> <body>
<div class="container"> <div class="container">
<div class="calendar-sticky" id="calendarSticky"> <div class="calendar-sticky" id="calendarSticky">
<header class="header"> <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> <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> </header>
<div class="weekdays"> <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>
<div class="calendar" id="calendar"></div> <div class="calendar" id="calendar"></div>
</div> </div>
<div class="duty-list" id="dutyList"></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="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden> <div class="access-denied" id="accessDenied" hidden></div>
<p>Доступ запрещён.</p> <div id="currentDutyView" class="current-duty-view hidden"></div>
</div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <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> </body>
</html> </html>

View File

@@ -9,16 +9,31 @@ import { t } from "./i18n.js";
/** /**
* Build fetch options with init data header, Accept-Language and timeout abort. * 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 * @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 = {}; const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData; if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru"; headers["Accept-Language"] = state.lang || "ru";
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); 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(). * Caller checks res.ok, res.status, res.json().
* @param {string} path - e.g. "/api/duties" * @param {string} path - e.g. "/api/duties"
* @param {{ from?: string, to?: string }} params - query params * @param {{ from?: string, to?: string }} params - query params
* @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation
* @returns {Promise<Response>} - raw response * @returns {Promise<Response>} - raw response
*/ */
export async function apiGet(path, params = {}) { export async function apiGet(path, params = {}, options = {}) {
const base = window.location.origin; const base = window.location.origin;
const query = new URLSearchParams(params).toString(); const query = new URLSearchParams(params).toString();
const url = query ? `${base}${path}?${query}` : `${base}${path}`; const url = query ? `${base}${path}?${query}` : `${base}${path}`;
const initData = getInitData(); const initData = getInitData();
const opts = buildFetchOptions(initData); const opts = buildFetchOptions(initData, options.signal);
try { try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
return res; return res;
} finally { } finally {
clearTimeout(opts.timeoutId); opts.cleanup();
} }
} }
/** /**
* Fetch duties for date range. Throws ACCESS_DENIED error on 403. * 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} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD * @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchDuties(from, to) { export async function fetchDuties(from, to, signal) {
try { const res = await apiGet("/api/duties", { from, to }, { signal });
const res = await apiGet("/api/duties", { from, to }); if (res.status === 403) {
if (res.status === 403) { let detail = t(state.lang, "access_denied");
let detail = t(state.lang, "access_denied"); try {
try { const body = await res.json();
const body = await res.json(); if (body && body.detail !== undefined) {
if (body && body.detail !== undefined) { detail =
detail = typeof body.detail === "string"
typeof body.detail === "string" ? body.detail
? body.detail : (body.detail.msg || JSON.stringify(body.detail));
: (body.detail.msg || JSON.stringify(body.detail));
}
} catch (parseErr) {
/* ignore */
} }
const err = new Error("ACCESS_DENIED"); } catch (parseErr) {
err.serverDetail = detail; /* ignore */
throw err;
} }
if (!res.ok) throw new Error(t(state.lang, "error_load_failed")); const err = new Error("ACCESS_DENIED");
return res.json(); err.serverDetail = detail;
} catch (e) { throw err;
if (e.name === "AbortError") {
throw new Error(t(state.lang, "error_network"));
}
throw e;
} }
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. * 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} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD * @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>} * @returns {Promise<object[]>}
*/ */
export async function fetchCalendarEvents(from, to) { export async function fetchCalendarEvents(from, to, signal) {
try { try {
const res = await apiGet("/api/calendar-events", { from, to }); const res = await apiGet("/api/calendar-events", { from, to }, { signal });
if (!res.ok) return []; if (!res.ok) return [];
return res.json(); return res.json();
} catch (e) { } catch (e) {
if (e.name === "AbortError") throw e;
return []; 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. * 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 { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js"; import { escapeHtml } from "./utils.js";
import { import {
@@ -29,6 +29,9 @@ export function calendarEventsByDate(events) {
return byDate; 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). * Group duties by local date (start_at/end_at are UTC).
* @param {object[]} duties - Duties with start_at, end_at * @param {object[]} duties - Duties with start_at, end_at
@@ -39,14 +42,17 @@ export function dutiesByDate(duties) {
duties.forEach((d) => { duties.forEach((d) => {
const start = new Date(d.start_at); const start = new Date(d.start_at);
const end = new Date(d.end_at); const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end); const endLocal = localDateString(end);
let t = new Date(start); let cursor = new Date(start);
while (true) { let iterations = 0;
const key = localDateString(t); while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = []; if (!byDate[key]) byDate[key] = [];
byDate[key].push(d); byDate[key].push(d);
if (key === endLocal) break; if (key === endLocal) break;
t.setDate(t.getDate() + 1); cursor.setDate(cursor.getDate() + 1);
iterations++;
} }
}); });
return byDate; return byDate;
@@ -65,6 +71,8 @@ export function renderCalendar(
dutiesByDateMap, dutiesByDateMap,
calendarEventsByDateMap calendarEventsByDateMap
) { ) {
const calendarEl = getCalendarEl();
const monthTitleEl = getMonthTitleEl();
if (!calendarEl || !monthTitleEl) return; if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1)); const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(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, start_at: x.start_at,
end_at: x.end_at end_at: x.end_at
})); }));
cell.setAttribute( cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
"data-day-duties", cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
JSON.stringify(dayPayload).replace(/"/g, "&quot;")
);
cell.setAttribute(
"data-day-events",
JSON.stringify(eventSummaries).replace(/"/g, "&quot;")
);
} }
const ariaParts = []; 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); 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). * Format ISO string as HH:MM (local).
* @param {string} isoStr - ISO date string * @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. * 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 { t } from "./i18n.js";
import { escapeHtml } from "./utils.js"; import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { localDateString, dateKeyToDDMM } from "./dateUtils.js"; import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
import { getDutyMarkerRows } from "./hints.js"; import { getDutyMarkerRows } from "./hints.js";
@@ -28,8 +29,7 @@ let sheetScrollY = 0;
function parseDataAttr(raw) { function parseDataAttr(raw) {
if (!raw) return []; if (!raw) return [];
try { try {
const s = raw.replace(/&quot;/g, '"'); const parsed = JSON.parse(raw);
const parsed = JSON.parse(s);
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed : [];
} catch (e) { } catch (e) {
return []; return [];
@@ -73,7 +73,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
); );
const rows = hasTimes const rows = hasTimes
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel) ? 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 += html +=
'<section class="day-detail-section day-detail-section--duty">' + '<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")) + escapeHtml(t(lang, "event_type.duty")) +
"</h3><ul class=" + "</h3><ul class=" +
'"day-detail-list">'; '"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 timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactLinksHtml(lang, phone, username, {
classPrefix: "day-detail-contact",
showLabels: true,
separator: " "
});
html += html +=
"<li>" + "<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") + (timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) + escapeHtml(r.fullName) +
(contactHtml ? contactHtml : "") +
"</li>"; "</li>";
}); });
html += "</ul></section>"; html += "</ul></section>";
@@ -153,6 +167,7 @@ function positionPopover(panel, cellRect) {
const panelRect = panel.getBoundingClientRect(); const panelRect = panel.getBoundingClientRect();
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2; let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
let top = cellRect.bottom + 8; 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) { if (top + panelRect.height > vh - margin) {
top = cellRect.top - panelRect.height - 8; top = cellRect.top - panelRect.height - 8;
panel.classList.add("day-detail-panel--below"); panel.classList.add("day-detail-panel--below");
@@ -210,6 +225,7 @@ function showAsPopover(cellRect) {
const target = e.target instanceof Node ? e.target : null; const target = e.target instanceof Node ? e.target : null;
if (!target || !panelEl) return; if (!target || !panelEl) return;
if (panelEl.contains(target)) return; if (panelEl.contains(target)) return;
const calendarEl = getCalendarEl();
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return; if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
hideDayDetail(); hideDayDetail();
}; };
@@ -344,6 +360,7 @@ function ensurePanelInDom() {
* Bind delegated click/keydown on calendar for .day cells. * Bind delegated click/keydown on calendar for .day cells.
*/ */
export function initDayDetail() { export function initDayDetail() {
const calendarEl = getCalendarEl();
if (!calendarEl) return; if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => { calendarEl.addEventListener("click", (e) => {
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null); 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("Петров"); const petrovPos = html.indexOf("Петров");
expect(ivanovPos).toBeLessThan(petrovPos); 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. * 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} */ /** @returns {HTMLDivElement|null} */
export const calendarEl = document.getElementById("calendar"); export function getCalendarEl() {
return document.getElementById("calendar");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const monthTitleEl = document.getElementById("monthTitle"); export function getMonthTitleEl() {
return document.getElementById("monthTitle");
}
/** @type {HTMLDivElement|null} */ /** @returns {HTMLDivElement|null} */
export const dutyListEl = document.getElementById("dutyList"); export function getDutyListEl() {
return document.getElementById("dutyList");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const loadingEl = document.getElementById("loading"); export function getLoadingEl() {
return document.getElementById("loading");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const errorEl = document.getElementById("error"); export function getErrorEl() {
return document.getElementById("error");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const accessDeniedEl = document.getElementById("accessDenied"); export function getAccessDeniedEl() {
return document.getElementById("accessDenied");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const headerEl = document.querySelector(".header"); export function getHeaderEl() {
return document.querySelector(".header");
}
/** @type {HTMLElement|null} */ /** @returns {HTMLElement|null} */
export const weekdaysEl = document.querySelector(".weekdays"); export function getWeekdaysEl() {
return document.querySelector(".weekdays");
}
/** @type {HTMLButtonElement|null} */ /** @returns {HTMLButtonElement|null} */
export const prevBtn = document.getElementById("prevMonth"); export function getPrevBtn() {
return document.getElementById("prevMonth");
}
/** @type {HTMLButtonElement|null} */ /** @returns {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth"); export function getNextBtn() {
return document.getElementById("nextMonth");
}
/** @returns {HTMLDivElement|null} */
export function getCurrentDutyViewEl() {
return document.getElementById("currentDutyView");
}
/** Currently viewed month (mutable). */ /** Currently viewed month (mutable). */
export const state = { export const state = {
@@ -41,5 +67,13 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|null} */ /** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null, todayRefreshInterval: null,
/** @type {'ru'|'en'} */ /** @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. * Duty list (timeline) rendering.
*/ */
import { dutyListEl, state } from "./dom.js"; import { getDutyListEl, state } from "./dom.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js"; import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { import {
localDateString, localDateString,
firstDayOfMonth, firstDayOfMonth,
lastDayOfMonth, lastDayOfMonth,
dateKeyToDDMM, dateKeyToDDMM,
formatTimeLocal, formatHHMM,
formatDateKey formatDateKey
} from "./dateUtils.js"; } 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. * 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 {object} d - Duty
* @param {boolean} isCurrent - Whether this is "current" duty * @param {boolean} isCurrent - Whether this is "current" duty
* @returns {string} * @returns {string}
@@ -25,8 +36,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
const endLocal = localDateString(new Date(d.end_at)); const endLocal = localDateString(new Date(d.end_at));
const startDDMM = dateKeyToDDMM(startLocal); const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal); const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatTimeLocal(d.start_at); const startTime = formatHHMM(d.start_at);
const endTime = formatTimeLocal(d.end_at); const endTime = formatHHMM(d.end_at);
let timeStr; let timeStr;
if (startLocal === endLocal) { if (startLocal === endLocal) {
timeStr = startDDMM + ", " + startTime + " " + endTime; timeStr = startDDMM + ", " + startTime + " " + endTime;
@@ -38,15 +49,66 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty") ? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty"))); : (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : ""; 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 ( return (
'<div class="duty-item duty-item--duty duty-timeline-card' + '<div class="duty-flip-card' +
extraClass + 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) + escapeHtml(typeLabel) +
'</span> <span class="name">' + '</span> <span class="name">' +
escapeHtml(d.full_name) + escapeHtml(d.full_name) +
'</span><div class="time">' + '</span><div class="time">' +
escapeHtml(timeStr) + 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>" "</div></div>"
); );
} }
@@ -69,14 +131,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
if (extraClass) itemClass += " " + extraClass; if (extraClass) itemClass += " " + extraClass;
let timeOrRange = ""; let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") { 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") { } else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at); const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at); const endStr = formatDateKey(d.end_at);
timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr; timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr;
} else { } else {
timeOrRange = timeOrRange =
formatTimeLocal(d.start_at) + " " + formatTimeLocal(d.end_at); formatHHMM(d.start_at) + " " + formatHHMM(d.end_at);
} }
return ( return (
'<div class="' + '<div class="' +
@@ -86,17 +148,33 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
'</span> <span class="name">' + '</span> <span class="name">' +
escapeHtml(d.full_name) + escapeHtml(d.full_name) +
'</span><div class="time">' + '</span><div class="time">' +
timeOrRange + escapeHtml(timeOrRange) +
"</div></div>" "</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. * Render duty list (timeline) for current month; scroll to today if visible.
* @param {object[]} duties - Duties (only duty type used for timeline) * @param {object[]} duties - Duties (only duty type used for timeline)
*/ */
export function renderDutyList(duties) { export function renderDutyList(duties) {
const dutyListEl = getDutyListEl();
if (!dutyListEl) return; 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"); const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) { if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline"); dutyListEl.classList.remove("duty-timeline");
@@ -174,8 +252,9 @@ export function renderDutyList(duties) {
el.scrollIntoView({ behavior: "smooth", block: "start" }); el.scrollIntoView({ behavior: "smooth", block: "start" });
} }
}; };
const currentDutyCard = dutyListEl.querySelector(".duty-item--current"); const listEl = getDutyListEl();
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today"); const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
if (currentDutyCard) { if (currentDutyCard) {
scrollToEl(currentDutyCard); scrollToEl(currentDutyCard);
} else if (todayBlock) { } 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. * 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 { t } from "./i18n.js";
import { escapeHtml } from "./utils.js"; import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js"; import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -126,9 +126,14 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa
timePrefix = toLabel + sep + endHHMM; timePrefix = toLabel + sep + endHHMM;
} }
} else if (idx > 0) { } else if (idx > 0) {
if (startHHMM) timePrefix = fromLabel + sep + startHHMM; if (startSameDay && startHHMM) {
if (endHHMM && endSameDay && endHHMM !== startHHMM) { timePrefix = fromLabel + sep + startHHMM;
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM; if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
/* Continuation from previous day — only end time */
timePrefix = toLabel + sep + endHHMM;
} }
} }
return timePrefix; return timePrefix;
@@ -245,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
* Remove active class from all duty/unavailable/vacation markers. * Remove active class from all duty/unavailable/vacation markers.
*/ */
export function clearActiveDutyMarker() { export function clearActiveDutyMarker() {
const calendarEl = getCalendarEl();
if (!calendarEl) return; if (!calendarEl) return;
calendarEl calendarEl
.querySelectorAll( .querySelectorAll(
@@ -253,148 +259,168 @@ export function clearActiveDutyMarker() {
.forEach((m) => m.classList.remove("calendar-marker-active")); .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() { export function dismissHint(hintEl, opts = {}) {
let hintEl = document.getElementById("calendarEventHint"); hintEl.classList.remove("calendar-event-hint--visible");
if (!hintEl) { const id = setTimeout(() => {
hintEl = document.createElement("div");
hintEl.id = "calendarEventHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
hintEl.hidden = true; hintEl.hidden = true;
document.body.appendChild(hintEl); hintEl.removeAttribute("data-active");
} if (opts.clearActive) clearActiveDutyMarker();
if (!calendarEl) return; if (typeof opts.afterHide === "function") opts.afterHide();
const lang = state.lang; }, HINT_FADE_MS);
calendarEl.querySelectorAll(".info-btn").forEach((btn) => { return id;
btn.addEventListener("click", (e) => { }
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || ""; const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
const content = t(lang, "hint.events") + "\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) { /**
hintEl.textContent = content; * Get or create the calendar event (info button) hint element.
const rect = btn.getBoundingClientRect(); * @returns {HTMLElement|null}
positionHint(hintEl, rect); */
hintEl.dataset.active = "1"; function getOrCreateCalendarEventHint() {
} else { let el = document.getElementById("calendarEventHint");
hintEl.classList.remove("calendar-event-hint--visible"); if (!el) {
setTimeout(() => { el = document.createElement("div");
hintEl.hidden = true; el.id = "calendarEventHint";
hintEl.removeAttribute("data-active"); el.className = "calendar-event-hint";
}, 150); el.setAttribute("role", "tooltip");
} el.hidden = true;
}); document.body.appendChild(el);
});
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);
}
});
} }
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() { function getOrCreateDutyMarkerHint() {
let hintEl = document.getElementById("dutyMarkerHint"); let el = document.getElementById("dutyMarkerHint");
if (!hintEl) { if (!el) {
hintEl = document.createElement("div"); el = document.createElement("div");
hintEl.id = "dutyMarkerHint"; el.id = "dutyMarkerHint";
hintEl.className = "calendar-event-hint"; el.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip"); el.setAttribute("role", "tooltip");
hintEl.hidden = true; el.hidden = true;
document.body.appendChild(hintEl); 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; if (!calendarEl) return;
let hideTimeout = null;
const selector = ".duty-marker, .unavailable-marker, .vacation-marker"; calendarEl.addEventListener("click", (e) => {
calendarEl.querySelectorAll(selector).forEach((marker) => { const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
marker.addEventListener("mouseenter", () => { if (btn) {
if (hideTimeout) { e.stopPropagation();
clearTimeout(hideTimeout); const summary = btn.getAttribute("data-summary") || "";
hideTimeout = null; const content = t(state.lang, "hint.events") + "\n" + summary;
} if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
const html = getDutyMarkerHintHtml(marker); calendarEventHint.textContent = content;
if (html) { positionHint(calendarEventHint, btn.getBoundingClientRect());
hintEl.innerHTML = html; calendarEventHint.dataset.active = "1";
} else { } else {
hintEl.textContent = getDutyMarkerHintContent(marker); dismissHint(calendarEventHint);
} }
const rect = marker.getBoundingClientRect(); return;
positionHint(hintEl, rect); }
hintEl.hidden = false;
}); const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
marker.addEventListener("mouseleave", () => { if (marker) {
if (hintEl.dataset.active) return;
hintEl.classList.remove("calendar-event-hint--visible");
hideTimeout = setTimeout(() => {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) { if (marker.classList.contains("calendar-marker-active")) {
hintEl.classList.remove("calendar-event-hint--visible"); dismissHint(dutyMarkerHint);
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
marker.classList.remove("calendar-marker-active"); marker.classList.remove("calendar-marker-active");
return; return;
} }
clearActiveDutyMarker(); clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker); const html = getDutyMarkerHintHtml(marker);
if (html) { if (html) {
hintEl.innerHTML = html; dutyMarkerHint.innerHTML = html;
} else { } else {
hintEl.textContent = getDutyMarkerHintContent(marker); dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
} }
const rect = marker.getBoundingClientRect(); positionHint(dutyMarkerHint, marker.getBoundingClientRect());
positionHint(hintEl, rect); dutyMarkerHint.hidden = false;
hintEl.hidden = false; dutyMarkerHint.dataset.active = "1";
hintEl.dataset.active = "1";
marker.classList.add("calendar-marker-active"); 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", () => { document.addEventListener("click", () => {
if (hintEl.dataset.active) { if (calendarEventHint.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible"); dismissHint(calendarEventHint);
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
} }
}); });
document.addEventListener("keydown", (e) => { document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) { if (e.key === "Escape" && calendarEventHint.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible"); dismissHint(calendarEventHint);
setTimeout(() => { }
hintEl.hidden = true; });
hintEl.removeAttribute("data-active"); }
clearActiveDutyMarker();
}, 150); 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. * Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay. * Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
* Also tests dismissHint helper.
*/ */
import { describe, it, expect, beforeAll } from "vitest"; import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { getDutyMarkerRows } from "./hints.js"; import { getDutyMarkerRows, dismissHint } from "./hints.js";
const FROM = "from"; const FROM = "from";
const TO = "until"; const TO = "until";
@@ -82,6 +83,33 @@ describe("getDutyMarkerRows", () => {
expect(rows[0].timePrefix).toContain("06:00"); expect(rows[0].timePrefix).toContain("06:00");
}); });
it("second duty continuation from previous day shows only end time (to HH:MM)", () => {
const hintDay = "2025-02-23";
const duties = [
{
full_name: "A",
start_at: "2025-02-23T00:00:00",
end_at: "2025-02-23T09:00:00",
},
{
full_name: "B",
start_at: "2025-02-22T09:00:00",
end_at: "2025-02-23T09:00:00",
},
];
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("A");
expect(rows[0].timePrefix).toContain(FROM);
expect(rows[0].timePrefix).toContain("00:00");
expect(rows[0].timePrefix).toContain(TO);
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[1].fullName).toBe("B");
expect(rows[1].timePrefix).not.toContain(FROM);
expect(rows[1].timePrefix).toContain(TO);
expect(rows[1].timePrefix).toContain("09:00");
});
it("multiple duties in one day — correct order when input is pre-sorted", () => { it("multiple duties in one day — correct order when input is pre-sorted", () => {
const hintDay = "2025-02-25"; const hintDay = "2025-02-25";
const duties = [ const duties = [
@@ -97,3 +125,52 @@ describe("getDutyMarkerRows", () => {
expect(rows[2].timePrefix).toContain("15:00"); 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.to": "until",
"hint.duty_title": "Duty:", "hint.duty_title": "Duty:",
"hint.events": "Events:", "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: { ru: {
"app.title": "Календарь дежурств", "app.title": "Календарь дежурств",
@@ -94,7 +104,17 @@ export const MESSAGES = {
"hint.to": "до", "hint.to": "до",
"hint.duty_title": "Дежурство:", "hint.duty_title": "Дежурство:",
"hint.events": "События:", "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 { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js"; import { getLang, t, weekdayLabels } from "./i18n.js";
import { getInitData } from "./auth.js"; import { getInitData, isLocalhost } from "./auth.js";
import { isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js"; import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import { import {
state, state,
accessDeniedEl, getAccessDeniedEl,
prevBtn, getPrevBtn,
nextBtn, getNextBtn,
loadingEl, getLoadingEl,
errorEl, getErrorEl,
weekdaysEl getWeekdaysEl
} from "./dom.js"; } from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js"; import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js"; import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -24,7 +23,9 @@ import {
renderCalendar renderCalendar
} from "./calendar.js"; } from "./calendar.js";
import { initDayDetail } from "./dayDetail.js"; import { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js"; import { renderDutyList } from "./dutyList.js";
import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js";
import { import {
firstDayOfMonth, firstDayOfMonth,
lastDayOfMonth, lastDayOfMonth,
@@ -38,15 +39,19 @@ initTheme();
state.lang = getLang(); state.lang = getLang();
document.documentElement.lang = state.lang; document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title"); document.title = t(state.lang, "app.title");
const loadingEl = getLoadingEl();
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null; const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading"); if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang); const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) { if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span"); const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => { spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[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 (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month")); if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
@@ -98,21 +103,33 @@ function requireTelegramOrLocalhost(onAllowed) {
return; return;
} }
showAccessDenied(undefined); showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden"); const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}, RETRY_DELAY_MS); }, RETRY_DELAY_MS);
return; return;
} }
showAccessDenied(undefined); 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. * 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() { async function loadMonth() {
if (loadMonthAbortController) loadMonthAbortController.abort();
loadMonthAbortController = new AbortController();
const signal = loadMonthAbortController.signal;
hideAccessDenied(); hideAccessDenied();
setNavEnabled(false); setNavEnabled(false);
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.remove("hidden"); if (loadingEl) loadingEl.classList.remove("hidden");
const errorEl = getErrorEl();
if (errorEl) errorEl.hidden = true; if (errorEl) errorEl.hidden = true;
const current = state.current; const current = state.current;
const first = firstDayOfMonth(current); const first = firstDayOfMonth(current);
@@ -122,8 +139,8 @@ async function loadMonth() {
const from = localDateString(start); const from = localDateString(start);
const to = localDateString(gridEnd); const to = localDateString(gridEnd);
try { try {
const dutiesPromise = fetchDuties(from, to); const dutiesPromise = fetchDuties(from, to, signal);
const eventsPromise = fetchCalendarEvents(from, to); const eventsPromise = fetchCalendarEvents(from, to, signal);
const duties = await dutiesPromise; const duties = await dutiesPromise;
const events = await eventsPromise; const events = await eventsPromise;
const byDate = dutiesByDate(duties); const byDate = dutiesByDate(duties);
@@ -156,15 +173,18 @@ async function loadMonth() {
}, 60000); }, 60000);
} }
} catch (e) { } catch (e) {
if (e.name === "AbortError") {
return;
}
if (e.message === "ACCESS_DENIED") { if (e.message === "ACCESS_DENIED") {
showAccessDenied(e.serverDetail); showAccessDenied(e.serverDetail);
setNavEnabled(true); setNavEnabled(true);
if ( if (
window.Telegram && window.Telegram &&
window.Telegram.WebApp && window.Telegram.WebApp &&
!window._initDataRetried !state.initDataRetried
) { ) {
window._initDataRetried = true; state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS); setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
} }
return; return;
@@ -173,21 +193,26 @@ async function loadMonth() {
setNavEnabled(true); setNavEnabled(true);
return; return;
} }
if (loadingEl) loadingEl.classList.add("hidden"); const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
setNavEnabled(true); setNavEnabled(true);
} }
if (prevBtn) { const prevBtnEl = getPrevBtn();
prevBtn.addEventListener("click", () => { if (prevBtnEl) {
prevBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return; if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return; if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1); state.current.setMonth(state.current.getMonth() - 1);
loadMonth(); loadMonth();
}); });
} }
if (nextBtn) { const nextBtnEl = getNextBtn();
nextBtn.addEventListener("click", () => { if (nextBtnEl) {
nextBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return; if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return; if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() + 1); state.current.setMonth(state.current.getMonth() + 1);
loadMonth(); loadMonth();
@@ -204,9 +229,9 @@ if (nextBtn) {
"touchstart", "touchstart",
(e) => { (e) => {
if (e.changedTouches.length === 0) return; if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0]; const touch = e.changedTouches[0];
startX = t.clientX; startX = touch.clientX;
startY = t.clientY; startY = touch.clientY;
}, },
{ passive: true } { passive: true }
); );
@@ -215,12 +240,15 @@ if (nextBtn) {
(e) => { (e) => {
if (e.changedTouches.length === 0) return; if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return; if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return; if (accessDeniedEl && !accessDeniedEl.hidden) return;
const t = e.changedTouches[0]; const touch = e.changedTouches[0];
const deltaX = t.clientX - startX; const deltaX = touch.clientX - startX;
const deltaY = t.clientY - startY; const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return; if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return; if (Math.abs(deltaY) > Math.abs(deltaX)) return;
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (deltaX > SWIPE_THRESHOLD) { if (deltaX > SWIPE_THRESHOLD) {
if (prevBtn && prevBtn.disabled) return; if (prevBtn && prevBtn.disabled) return;
state.current.setMonth(state.current.getMonth() - 1); state.current.setMonth(state.current.getMonth() - 1);
@@ -237,8 +265,8 @@ if (nextBtn) {
function bindStickyScrollShadow() { function bindStickyScrollShadow() {
const stickyEl = document.getElementById("calendarSticky"); const stickyEl = document.getElementById("calendarSticky");
if (!stickyEl || document._stickyScrollBound) return; if (!stickyEl || state.stickyScrollBound) return;
document._stickyScrollBound = true; state.stickyScrollBound = true;
function updateScrolled() { function updateScrolled() {
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0); stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
} }
@@ -250,6 +278,19 @@ runWhenReady(() => {
requireTelegramOrLocalhost(() => { requireTelegramOrLocalhost(() => {
bindStickyScrollShadow(); bindStickyScrollShadow();
initDayDetail(); 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 { import {
state, state,
calendarEl, getCalendarEl,
dutyListEl, getDutyListEl,
loadingEl, getLoadingEl,
errorEl, getErrorEl,
accessDeniedEl, getAccessDeniedEl,
headerEl, getHeaderEl,
weekdaysEl, getWeekdaysEl,
prevBtn, getPrevBtn,
nextBtn getNextBtn
} from "./dom.js"; } from "./dom.js";
import { t } from "./i18n.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) * @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
*/ */
export function showAccessDenied(serverDetail) { 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 (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true; if (weekdaysEl) weekdaysEl.hidden = true;
if (calendarEl) calendarEl.hidden = true; if (calendarEl) calendarEl.hidden = true;
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
* Hide access-denied and show calendar/list/header/weekdays. * Hide access-denied and show calendar/list/header/weekdays.
*/ */
export function hideAccessDenied() { export function hideAccessDenied() {
const accessDeniedEl = getAccessDeniedEl();
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
if (accessDeniedEl) accessDeniedEl.hidden = true; if (accessDeniedEl) accessDeniedEl.hidden = true;
if (headerEl) headerEl.hidden = false; if (headerEl) headerEl.hidden = false;
if (weekdaysEl) weekdaysEl.hidden = false; if (weekdaysEl) weekdaysEl.hidden = false;
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
* @param {string} msg - Error text * @param {string} msg - Error text
*/ */
export function showError(msg) { export function showError(msg) {
const errorEl = getErrorEl();
const loadingEl = getLoadingEl();
if (errorEl) { if (errorEl) {
errorEl.textContent = msg; errorEl.textContent = msg;
errorEl.hidden = false; errorEl.hidden = false;
@@ -68,6 +82,8 @@ export function showError(msg) {
* @param {boolean} enabled * @param {boolean} enabled
*/ */
export function setNavEnabled(enabled) { export function setNavEnabled(enabled) {
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.disabled = !enabled; if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.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. * Common utilities.
*/ */
const ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/** /**
* Escape string for safe use in HTML (text content / attributes). * Escape string for safe use in HTML (text content / attributes).
* @param {string} s - Raw string * @param {string} s - Raw string
* @returns {string} HTML-escaped string * @returns {string} HTML-escaped string
*/ */
export function escapeHtml(s) { export function escapeHtml(s) {
const div = document.createElement("div"); return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
div.textContent = s;
return div.innerHTML;
} }

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