Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d28123d0b | |||
| 2e78b3c1e6 | |||
| bdead6eef7 | |||
| 2fb553567f | |||
| e3240d0981 | |||
| f8aceabab5 | |||
| 322b553b80 | |||
| a4d8d085c6 |
@@ -39,3 +39,14 @@ jobs:
|
||||
- name: Security check with Bandit
|
||||
run: |
|
||||
bandit -r duty_teller -ll
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp tests
|
||||
run: |
|
||||
cd webapp
|
||||
npm ci
|
||||
npm run test
|
||||
|
||||
32
alembic/versions/009_trusted_groups.py
Normal file
32
alembic/versions/009_trusted_groups.py
Normal 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")
|
||||
@@ -5,6 +5,8 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
@@ -56,6 +58,22 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/app/") and (
|
||||
path.endswith(".js") or path.endswith(".html")
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
@@ -126,7 +144,9 @@ def get_team_calendar_ical(
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
duties_duty_only = [
|
||||
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
|
||||
(d, name)
|
||||
for d, name, *_ in all_duties
|
||||
if (d.event_type or "duty") == "duty"
|
||||
]
|
||||
ics_bytes = build_team_ics(duties_duty_only)
|
||||
ics_calendar_cache.set(cache_key, ics_bytes)
|
||||
|
||||
@@ -190,7 +190,7 @@ def fetch_duties_response(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
@@ -203,6 +203,8 @@ def fetch_duties_response(
|
||||
event_type=(
|
||||
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
|
||||
),
|
||||
phone=phone,
|
||||
username=username,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
for duty, full_name, phone, username in rows
|
||||
]
|
||||
|
||||
@@ -52,6 +52,7 @@ class Settings:
|
||||
|
||||
bot_token: str
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
@@ -93,9 +94,11 @@ class Settings:
|
||||
)
|
||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||
http_host = raw_host if raw_host else "127.0.0.1"
|
||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
||||
return cls(
|
||||
bot_token=bot_token,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
http_host=http_host,
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
@@ -123,6 +126,7 @@ _settings = Settings.from_env()
|
||||
|
||||
BOT_TOKEN = _settings.bot_token
|
||||
DATABASE_URL = _settings.database_url
|
||||
BOT_USERNAME = _settings.bot_username
|
||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||
HTTP_HOST = _settings.http_host
|
||||
HTTP_PORT = _settings.http_port
|
||||
|
||||
@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
|
||||
class TrustedGroup(Base):
|
||||
"""Groups authorized to receive duty information."""
|
||||
|
||||
__tablename__ = "trusted_groups"
|
||||
|
||||
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
|
||||
added_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
added_at: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
@@ -11,6 +11,7 @@ from duty_teller.db.models import (
|
||||
User,
|
||||
Duty,
|
||||
GroupDutyPin,
|
||||
TrustedGroup,
|
||||
CalendarSubscriptionToken,
|
||||
Role,
|
||||
)
|
||||
@@ -316,8 +317,8 @@ def get_duties(
|
||||
session: Session,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
"""Return duties overlapping the given date range with user full_name.
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties overlapping the given date range with user full_name, phone, username.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
@@ -325,11 +326,11 @@ def get_duties(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
|
||||
)
|
||||
@@ -342,7 +343,7 @@ def get_duties_for_user(
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
event_types: list[str] | None = None,
|
||||
) -> list[tuple[Duty, str]]:
|
||||
) -> list[tuple[Duty, str, str | None, str | None]]:
|
||||
"""Return duties for one user overlapping the date range.
|
||||
|
||||
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
|
||||
@@ -356,7 +357,7 @@ def get_duties_for_user(
|
||||
event_types: If not None, only return duties whose event_type is in this list.
|
||||
|
||||
Returns:
|
||||
List of (Duty, full_name) tuples.
|
||||
List of (Duty, full_name, phone, username) tuples.
|
||||
"""
|
||||
to_date_next = to_date_exclusive_iso(to_date)
|
||||
filters = [
|
||||
@@ -367,7 +368,7 @@ def get_duties_for_user(
|
||||
if event_types is not None:
|
||||
filters.append(Duty.event_type.in_(event_types))
|
||||
q = (
|
||||
session.query(Duty, User.full_name)
|
||||
session.query(Duty, User.full_name, User.phone, User.username)
|
||||
.join(User, Duty.user_id == User.id)
|
||||
.filter(*filters)
|
||||
)
|
||||
@@ -593,6 +594,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def is_trusted_group(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the chat is in the trusted groups list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return (
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def add_trusted_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> TrustedGroup:
|
||||
"""Add a group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
|
||||
Returns:
|
||||
Created TrustedGroup instance.
|
||||
"""
|
||||
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
record = TrustedGroup(
|
||||
chat_id=chat_id,
|
||||
added_by_user_id=added_by_user_id,
|
||||
added_at=now_iso,
|
||||
)
|
||||
session.add(record)
|
||||
session.commit()
|
||||
session.refresh(record)
|
||||
return record
|
||||
|
||||
|
||||
def remove_trusted_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove a group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
|
||||
session.commit()
|
||||
|
||||
|
||||
def get_all_trusted_group_ids(session: Session) -> list[int]:
|
||||
"""Return all chat_ids that are trusted.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of trusted chat ids.
|
||||
"""
|
||||
rows = session.query(TrustedGroup.chat_id).all()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def set_user_phone(
|
||||
session: Session, telegram_user_id: int, phone: str | None
|
||||
) -> User | None:
|
||||
|
||||
@@ -55,13 +55,16 @@ class DutyInDb(DutyBase):
|
||||
|
||||
|
||||
class DutyWithUser(DutyInDb):
|
||||
"""Duty with full_name and event_type for calendar display.
|
||||
"""Duty with full_name, event_type, and optional contact fields for calendar display.
|
||||
|
||||
event_type: only these values are returned; unknown DB values are mapped to "duty" in the API.
|
||||
phone and username are exposed only to authenticated Mini App users (role-gated).
|
||||
"""
|
||||
|
||||
full_name: str
|
||||
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
|
||||
phone: str | None = None
|
||||
username: str | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@@ -22,4 +22,6 @@ def register_handlers(app: Application) -> None:
|
||||
app.add_handler(group_duty_pin.group_duty_pin_handler)
|
||||
app.add_handler(group_duty_pin.pin_duty_handler)
|
||||
app.add_handler(group_duty_pin.refresh_pin_handler)
|
||||
app.add_handler(group_duty_pin.trust_group_handler)
|
||||
app.add_handler(group_duty_pin.untrust_group_handler)
|
||||
app.add_error_handler(errors.error_handler)
|
||||
|
||||
@@ -168,6 +168,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
if await is_admin_async(update.effective_user.id):
|
||||
lines.append(t(lang, "help.import_schedule"))
|
||||
lines.append(t(lang, "help.set_role"))
|
||||
lines.append(t(lang, "help.trust_group"))
|
||||
lines.append(t(lang, "help.untrust_group"))
|
||||
await update.message.reply_text("\n".join(lines))
|
||||
|
||||
|
||||
|
||||
@@ -6,13 +6,14 @@ from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||
from telegram.constants import ChatMemberStatus
|
||||
from telegram.error import BadRequest, Forbidden
|
||||
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
|
||||
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import get_lang, t
|
||||
from duty_teller.handlers.common import is_admin_async
|
||||
from duty_teller.services.group_duty_pin_service import (
|
||||
get_duty_message_text,
|
||||
get_message_id,
|
||||
@@ -21,6 +22,9 @@ from duty_teller.services.group_duty_pin_service import (
|
||||
save_pin,
|
||||
delete_pin,
|
||||
get_all_pin_chat_ids,
|
||||
is_group_trusted,
|
||||
trust_group,
|
||||
untrust_group,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,6 +66,54 @@ def _sync_get_message_id(chat_id: int) -> int | None:
|
||||
return get_message_id(session, chat_id)
|
||||
|
||||
|
||||
def _sync_is_trusted(chat_id: int) -> bool:
|
||||
"""Check if the group is trusted (sync wrapper for handlers)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
return is_group_trusted(session, chat_id)
|
||||
|
||||
|
||||
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
|
||||
"""Add group to trusted list. Returns True if already trusted (no-op)."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if is_group_trusted(session, chat_id):
|
||||
return True
|
||||
trust_group(session, chat_id, added_by_user_id)
|
||||
return False
|
||||
|
||||
|
||||
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
||||
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
|
||||
|
||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||
"""
|
||||
if not config.BOT_USERNAME:
|
||||
return None
|
||||
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||
button = InlineKeyboardButton(
|
||||
text=t(lang, "pin_duty.view_contacts"),
|
||||
url=url,
|
||||
)
|
||||
return InlineKeyboardMarkup([[button]])
|
||||
|
||||
|
||||
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
|
||||
"""Remove group from trusted list.
|
||||
|
||||
Returns:
|
||||
(was_trusted, message_id): was_trusted False if group was not in list;
|
||||
message_id of pinned message if any (for cleanup), else None.
|
||||
"""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
if not is_group_trusted(session, chat_id):
|
||||
return (False, None)
|
||||
message_id = get_message_id(session, chat_id)
|
||||
delete_pin(session, chat_id)
|
||||
untrust_group(session, chat_id)
|
||||
return (True, message_id)
|
||||
|
||||
|
||||
async def _schedule_next_update(
|
||||
application, chat_id: int, when_utc: datetime | None
|
||||
) -> None:
|
||||
@@ -102,17 +154,40 @@ async def _schedule_next_update(
|
||||
|
||||
async def _refresh_pin_for_chat(
|
||||
context: ContextTypes.DEFAULT_TYPE, chat_id: int
|
||||
) -> Literal["updated", "no_message", "failed"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
|
||||
) -> Literal["updated", "no_message", "failed", "untrusted"]:
|
||||
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
|
||||
|
||||
Uses single DB session for message_id, text, next_shift_end (consolidated).
|
||||
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
|
||||
|
||||
Returns:
|
||||
"updated" if the message was sent, pinned and saved successfully;
|
||||
"no_message" if there is no pin record for this chat;
|
||||
"failed" if send_message or permissions failed.
|
||||
"failed" if send_message or permissions failed;
|
||||
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
|
||||
return "untrusted"
|
||||
message_id, text, next_end = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
|
||||
@@ -120,8 +195,13 @@ async def _refresh_pin_for_chat(
|
||||
if message_id is None:
|
||||
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
||||
return "no_message"
|
||||
old_message_id = message_id
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
|
||||
@@ -140,6 +220,16 @@ async def _refresh_pin_for_chat(
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Could not delete old pinned message %s in chat_id=%s: %s",
|
||||
old_message_id,
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
|
||||
@@ -175,12 +265,27 @@ async def my_chat_member_handler(
|
||||
ChatMemberStatus.BANNED,
|
||||
):
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
lang = get_lang(update.effective_user)
|
||||
try:
|
||||
await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=t(lang, "group.not_trusted"),
|
||||
)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
return
|
||||
lang = get_lang(update.effective_user)
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
|
||||
return
|
||||
@@ -244,13 +349,21 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(chat_id=chat_id, text=text)
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
|
||||
@@ -301,13 +414,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
||||
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
|
||||
if not trusted:
|
||||
await update.message.reply_text(t(lang, "group.not_trusted"))
|
||||
return
|
||||
result = await _refresh_pin_for_chat(context, chat_id)
|
||||
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
|
||||
|
||||
|
||||
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /trust_group: add current group to trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "trust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
already_trusted = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: _sync_trust_group(
|
||||
chat_id, update.effective_user.id if update.effective_user else None
|
||||
),
|
||||
)
|
||||
if already_trusted:
|
||||
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
|
||||
return
|
||||
await update.message.reply_text(t(lang, "trust_group.added"))
|
||||
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
|
||||
if message_id is None:
|
||||
text = await loop.run_in_executor(
|
||||
None, lambda: _get_duty_message_text_sync(lang)
|
||||
)
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
text=text,
|
||||
reply_markup=_get_contact_button_markup(lang),
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message after trust_group chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
return
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
|
||||
|
||||
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
|
||||
if not update.message or not update.effective_chat or not update.effective_user:
|
||||
return
|
||||
chat = update.effective_chat
|
||||
lang = get_lang(update.effective_user)
|
||||
if chat.type not in ("group", "supergroup"):
|
||||
await update.message.reply_text(t(lang, "untrust_group.group_only"))
|
||||
return
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
chat_id = chat.id
|
||||
loop = asyncio.get_running_loop()
|
||||
was_trusted, message_id = await loop.run_in_executor(
|
||||
None, _sync_untrust_group, chat_id
|
||||
)
|
||||
if not was_trusted:
|
||||
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
|
||||
return
|
||||
name = f"{JOB_NAME_PREFIX}{chat_id}"
|
||||
if context.application.job_queue:
|
||||
for job in context.application.job_queue.get_jobs_by_name(name):
|
||||
job.schedule_removal()
|
||||
if message_id is not None:
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
try:
|
||||
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||
except (BadRequest, Forbidden):
|
||||
pass
|
||||
await update.message.reply_text(t(lang, "untrust_group.removed"))
|
||||
|
||||
|
||||
group_duty_pin_handler = ChatMemberHandler(
|
||||
my_chat_member_handler,
|
||||
ChatMemberHandler.MY_CHAT_MEMBER,
|
||||
)
|
||||
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
|
||||
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
|
||||
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
|
||||
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)
|
||||
|
||||
@@ -18,6 +18,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.",
|
||||
"refresh_pin.updated": "Pinned duty message updated.",
|
||||
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).",
|
||||
"refresh_pin.untrusted": "Group was removed from trusted list; pin record cleared.",
|
||||
"trust_group.added": "Group added to trusted list.",
|
||||
"trust_group.already_trusted": "This group is already trusted.",
|
||||
"trust_group.group_only": "The /trust_group command works only in groups.",
|
||||
"untrust_group.removed": "Group removed from trusted list.",
|
||||
"untrust_group.not_trusted": "This group is not in the trusted list.",
|
||||
"untrust_group.group_only": "The /untrust_group command works only in groups.",
|
||||
"group.not_trusted": (
|
||||
"This group is not authorized to receive duty data. "
|
||||
"An administrator can add the group with /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — In a group: add group to trusted list (admin only)",
|
||||
"help.untrust_group": "/untrust_group — In a group: remove group from trusted list (admin only)",
|
||||
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
|
||||
"calendar_link.access_denied": "Access denied.",
|
||||
"calendar_link.success": (
|
||||
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"administrator with «Pin messages» permission, then send /pin_duty in the "
|
||||
"chat — the current message will be pinned."
|
||||
),
|
||||
"pin_duty.view_contacts": "View contacts",
|
||||
"duty.no_duty": "No duty at the moment.",
|
||||
"duty.label": "Duty:",
|
||||
"import.admin_only": "Access for administrators only.",
|
||||
@@ -74,6 +88,13 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"api.access_denied": "Access denied",
|
||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||
"dates.from_after_to": "from date must not be after to",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar",
|
||||
},
|
||||
"ru": {
|
||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||
@@ -92,6 +113,19 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
|
||||
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
|
||||
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).",
|
||||
"refresh_pin.untrusted": "Группа удалена из доверенных; запись о закреплении сброшена.",
|
||||
"trust_group.added": "Группа добавлена в доверенные.",
|
||||
"trust_group.already_trusted": "Эта группа уже в доверенных.",
|
||||
"trust_group.group_only": "Команда /trust_group работает только в группах.",
|
||||
"untrust_group.removed": "Группа удалена из доверенных.",
|
||||
"untrust_group.not_trusted": "Эта группа не в доверенных.",
|
||||
"untrust_group.group_only": "Команда /untrust_group работает только в группах.",
|
||||
"group.not_trusted": (
|
||||
"Эта группа не авторизована для получения данных дежурных. "
|
||||
"Администратор может добавить группу командой /trust_group."
|
||||
),
|
||||
"help.trust_group": "/trust_group — В группе: добавить группу в доверенные (только админ)",
|
||||
"help.untrust_group": "/untrust_group — В группе: удалить группу из доверенных (только админ)",
|
||||
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
|
||||
"calendar_link.access_denied": "Доступ запрещён.",
|
||||
"calendar_link.success": (
|
||||
@@ -116,6 +150,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
|
||||
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
|
||||
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
|
||||
"pin_duty.view_contacts": "Контакты",
|
||||
"duty.no_duty": "Сейчас дежурства нет.",
|
||||
"duty.label": "Дежурство:",
|
||||
"import.admin_only": "Доступ только для администраторов.",
|
||||
@@ -136,5 +171,12 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"api.access_denied": "Доступ запрещён",
|
||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ from duty_teller.config import require_bot_token
|
||||
from duty_teller.handlers import group_duty_pin, register_handlers
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
|
||||
async def _resolve_bot_username(application) -> None:
|
||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||
if not config.BOT_USERNAME:
|
||||
me = await application.bot.get_me()
|
||||
config.BOT_USERNAME = (me.username or "").lower()
|
||||
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=logging.INFO,
|
||||
@@ -69,6 +78,7 @@ def main() -> None:
|
||||
ApplicationBuilder()
|
||||
.token(config.BOT_TOKEN)
|
||||
.post_init(group_duty_pin.restore_group_pin_jobs)
|
||||
.post_init(_resolve_bot_username)
|
||||
.build()
|
||||
)
|
||||
register_handlers(app)
|
||||
|
||||
@@ -13,6 +13,9 @@ from duty_teller.db.repository import (
|
||||
save_group_duty_pin,
|
||||
delete_group_duty_pin,
|
||||
get_all_group_duty_pin_chat_ids,
|
||||
is_trusted_group,
|
||||
add_trusted_group,
|
||||
remove_trusted_group,
|
||||
)
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.utils.dates import parse_utc_iso
|
||||
@@ -82,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
|
||||
f"🕐 {label} {time_range}",
|
||||
f"👤 {user.full_name}",
|
||||
]
|
||||
if user.phone:
|
||||
lines.append(f"📞 {user.phone}")
|
||||
if user.username:
|
||||
lines.append(f"@{user.username}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -164,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
|
||||
List of chat ids.
|
||||
"""
|
||||
return get_all_group_duty_pin_chat_ids(session)
|
||||
|
||||
|
||||
def is_group_trusted(session: Session, chat_id: int) -> bool:
|
||||
"""Check if the group is in the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
|
||||
Returns:
|
||||
True if the group is trusted.
|
||||
"""
|
||||
return is_trusted_group(session, chat_id)
|
||||
|
||||
|
||||
def trust_group(
|
||||
session: Session, chat_id: int, added_by_user_id: int | None = None
|
||||
) -> None:
|
||||
"""Add the group to the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
added_by_user_id: Telegram user id of the admin who added the group (optional).
|
||||
"""
|
||||
add_trusted_group(session, chat_id, added_by_user_id)
|
||||
|
||||
|
||||
def untrust_group(session: Session, chat_id: int) -> None:
|
||||
"""Remove the group from the trusted list.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
chat_id: Telegram chat id.
|
||||
"""
|
||||
remove_trusted_group(session, chat_id)
|
||||
|
||||
@@ -71,3 +71,31 @@ class TestValidateDutyDates:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "From after to message"
|
||||
mock_t.assert_called_with("ru", "dates.from_after_to")
|
||||
|
||||
|
||||
class TestFetchDutiesResponse:
|
||||
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
|
||||
|
||||
def test_fetch_duties_response_includes_phone_and_username(self):
|
||||
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from duty_teller.db.schemas import DutyWithUser
|
||||
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
|
||||
with patch.object(deps, "get_duties", return_value=rows):
|
||||
result = deps.fetch_duties_response(
|
||||
type("Session", (), {})(), "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], DutyWithUser)
|
||||
assert result[0].full_name == "Alice"
|
||||
assert result[0].phone == "+79001234567"
|
||||
assert result[0].username == "alice_dev"
|
||||
|
||||
@@ -254,7 +254,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
)
|
||||
|
||||
def fake_get_duties(session, from_date, to_date):
|
||||
return [(fake_duty, "User A")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
return [(fake_duty, "User A", "+79001234567", "user_a")]
|
||||
|
||||
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
|
||||
r = client.get(
|
||||
@@ -266,6 +267,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
assert len(data) == 1
|
||||
assert data[0]["event_type"] == "duty"
|
||||
assert data[0]["full_name"] == "User A"
|
||||
assert data[0].get("phone") == "+79001234567"
|
||||
assert data[0].get("username") == "user_a"
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
@@ -311,7 +314,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
end_at="2026-06-16T18:00:00Z",
|
||||
event_type="vacation",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [
|
||||
(duty, "User A", None, None),
|
||||
(non_duty, "User B", None, None),
|
||||
]
|
||||
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
|
||||
token = "y" * 43
|
||||
|
||||
@@ -371,7 +378,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = (
|
||||
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
|
||||
)
|
||||
@@ -415,7 +423,8 @@ def test_calendar_ical_ignores_unknown_query_params(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
|
||||
token = "z" * 43
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_duty_and_user_returns_formatted(self):
|
||||
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
|
||||
duty = SimpleNamespace(
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
@@ -94,9 +95,10 @@ class TestFormatDutyMessage:
|
||||
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
|
||||
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
|
||||
assert "Иван Иванов" in result
|
||||
assert "+79001234567" in result or "79001234567" in result
|
||||
assert "@ivan" in result
|
||||
assert "Duty" in result
|
||||
# Contact info is restricted to Mini App; not shown in pinned group message.
|
||||
assert "+79001234567" not in result and "79001234567" not in result
|
||||
assert "@ivan" not in result
|
||||
|
||||
|
||||
class TestGetDutyMessageText:
|
||||
|
||||
@@ -11,6 +11,13 @@ import duty_teller.config as config
|
||||
from duty_teller.handlers import group_duty_pin as mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_mini_app_url():
|
||||
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
yield
|
||||
|
||||
|
||||
class TestSyncWrappers:
|
||||
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
||||
|
||||
@@ -76,6 +83,30 @@ class TestSyncWrappers:
|
||||
# --- _schedule_next_update ---
|
||||
|
||||
|
||||
def test_get_contact_button_markup_empty_username_returns_none():
|
||||
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
assert mod._get_contact_button_markup("en") is None
|
||||
|
||||
|
||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
assert len(result.inline_keyboard) == 1
|
||||
assert len(result.inline_keyboard[0]) == 1
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.text == "View contacts"
|
||||
assert btn.url.startswith("https://t.me/")
|
||||
assert "startapp=duty" in btn.url
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
@@ -127,7 +158,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
|
||||
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
@@ -137,24 +168,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None)
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=123, text="Current duty", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=123, message_id=999, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
|
||||
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 999
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 123}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock(
|
||||
side_effect=BadRequest("Message to delete not found")
|
||||
)
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod,
|
||||
"_sync_get_pin_refresh_data",
|
||||
return_value=(1, "Current duty", None),
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
mock_save.assert_called_once_with(123, 999)
|
||||
mock_schedule.assert_called_once_with(context.application, 123, None)
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -166,10 +243,11 @@ async def test_update_group_pin_no_message_id_skips():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
|
||||
):
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@@ -185,11 +263,14 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.unpin_chat_message.assert_not_called()
|
||||
context.bot.pin_chat_message.assert_not_called()
|
||||
mock_schedule.assert_called_once_with(context.application, 111, None)
|
||||
@@ -212,16 +293,19 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(mod, "logger") as mock_logger:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
mock_save.assert_not_called()
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
|
||||
@@ -230,7 +314,7 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
|
||||
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 777
|
||||
context = MagicMock()
|
||||
@@ -240,23 +324,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", False):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=333, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=333, message_id=777, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(333, 777)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
|
||||
mock_schedule.assert_called_once_with(context.application, 333, None)
|
||||
|
||||
|
||||
@@ -295,16 +384,41 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=5, disable_notification=True
|
||||
)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_untrusted_group_rejects():
|
||||
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
|
||||
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
|
||||
@@ -327,21 +441,26 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=42, disable_notification=True
|
||||
)
|
||||
@@ -366,15 +485,20 @@ async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
mock_save.assert_not_called()
|
||||
@@ -403,19 +527,26 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty", reply_markup=None
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 43)
|
||||
update.message.reply_text.assert_called_once_with("Make me admin to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
|
||||
@@ -438,10 +569,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
|
||||
)
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed to pin"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed to pin")
|
||||
mock_t.assert_called_with("en", "pin_duty.failed")
|
||||
|
||||
@@ -482,12 +614,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Updated"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Updated")
|
||||
mock_t.assert_called_with("en", "refresh_pin.updated")
|
||||
|
||||
@@ -505,12 +638,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "No message"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("No message")
|
||||
mock_t.assert_called_with("en", "refresh_pin.no_message")
|
||||
|
||||
@@ -528,16 +662,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
|
||||
):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Failed"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Failed")
|
||||
mock_t.assert_called_with("en", "refresh_pin.failed")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_cmd_untrusted_group_rejects():
|
||||
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(
|
||||
mod, "_refresh_pin_for_chat", AsyncMock()
|
||||
) as mock_refresh:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.refresh_pin_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
mock_refresh.assert_not_called()
|
||||
|
||||
|
||||
# --- my_chat_member_handler ---
|
||||
|
||||
|
||||
@@ -586,12 +746,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
|
||||
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not authorized"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
|
||||
mock_t.assert_called_with("en", "group.not_trusted")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_my_chat_member_handler_trusted_group_sends_duty():
|
||||
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
|
||||
update = _make_my_chat_member_update(
|
||||
old_status=ChatMemberStatus.LEFT,
|
||||
new_status=ChatMemberStatus.ADMINISTRATOR,
|
||||
chat_id=200,
|
||||
bot_id=999,
|
||||
)
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.id = 999
|
||||
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=200, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=200, message_id=42, disable_notification=True
|
||||
)
|
||||
@@ -617,13 +845,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
|
||||
with patch.object(mod, "_sync_save_pin"):
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(mod, "_schedule_next_update", AsyncMock()):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Make me admin to pin"
|
||||
await mod.my_chat_member_handler(update, context)
|
||||
assert context.bot.send_message.call_count >= 2
|
||||
pin_hint_calls = [
|
||||
c
|
||||
@@ -674,3 +907,196 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
assert mock_schedule.call_count == 2
|
||||
mock_schedule.assert_any_call(application, 10, None)
|
||||
mock_schedule.assert_any_call(application, 20, None)
|
||||
|
||||
|
||||
# --- _refresh_pin_for_chat untrusted ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_pin_for_chat_untrusted_removes_pin():
|
||||
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=11):
|
||||
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
|
||||
result = await mod._refresh_pin_for_chat(context, 100)
|
||||
assert result == "untrusted"
|
||||
mock_delete_pin.assert_called_once_with(100)
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
|
||||
|
||||
|
||||
# --- trust_group_cmd / untrust_group_cmd ---
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_non_admin_rejects():
|
||||
"""trust_group_cmd: non-admin -> reply import.admin_only."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Admin only"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Admin only")
|
||||
mock_t.assert_called_with("en", "import.admin_only")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_adds_group():
|
||||
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 50
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
context.application.job_queue.run_once = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=False):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_get_duty_message_text_sync", return_value="Duty text"
|
||||
):
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
with patch.object(
|
||||
mod, "_get_next_shift_end_sync", return_value=None
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.handlers.group_duty_pin.t"
|
||||
) as mock_t:
|
||||
mock_t.return_value = "Added"
|
||||
with patch.object(
|
||||
config, "DUTY_PIN_NOTIFY", False
|
||||
):
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_any_call("Added")
|
||||
mock_t.assert_any_call("en", "trust_group.added")
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=100, text="Duty text", reply_markup=None
|
||||
)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=50, disable_notification=True
|
||||
)
|
||||
mock_save.assert_called_once_with(100, 50)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
|
||||
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_trust_group", return_value=True):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Already trusted"
|
||||
await mod.trust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Already trusted")
|
||||
mock_t.assert_called_with("en", "trust_group.already_trusted")
|
||||
context.bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_removes_group():
|
||||
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
update.effective_user.id = 111
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
mock_job = MagicMock()
|
||||
context.application.job_queue = MagicMock()
|
||||
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Removed"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Removed")
|
||||
mock_t.assert_called_with("en", "untrust_group.removed")
|
||||
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
|
||||
"duty_pin_100"
|
||||
)
|
||||
mock_job.schedule_removal.assert_called_once()
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
|
||||
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
|
||||
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
update.effective_chat = MagicMock()
|
||||
update.effective_chat.type = "group"
|
||||
update.effective_chat.id = 100
|
||||
update.effective_user = MagicMock()
|
||||
context = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
|
||||
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Not trusted"
|
||||
await mod.untrust_group_cmd(update, context)
|
||||
update.message.reply_text.assert_called_once_with("Not trusted")
|
||||
mock_t.assert_called_with("en", "untrust_group.not_trusted")
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
|
||||
assert "2026-02-16T06:00:00Z" in starts
|
||||
assert "2026-02-17T06:00:00Z" in starts
|
||||
assert "2026-02-18T06:00:00Z" in starts
|
||||
for d, _ in duties:
|
||||
for d, *_ in duties:
|
||||
assert d.event_type == "duty"
|
||||
|
||||
|
||||
|
||||
84
tests/test_trusted_groups_repository.py
Normal file
84
tests/test_trusted_groups_repository.py
Normal 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
94
webapp/css/base.css
Normal 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
197
webapp/css/calendar.css
Normal 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
219
webapp/css/day-detail.css
Normal 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
330
webapp/css/duty-list.css
Normal 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
70
webapp/css/hints.css
Normal 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
45
webapp/css/markers.css
Normal 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
306
webapp/css/states.css
Normal 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;
|
||||
}
|
||||
@@ -1,33 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<link rel="icon" href="favicon.png" type="image/png">
|
||||
<title>Календарь дежурств</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="css/base.css">
|
||||
<link rel="stylesheet" href="css/calendar.css">
|
||||
<link rel="stylesheet" href="css/day-detail.css">
|
||||
<link rel="stylesheet" href="css/hints.css">
|
||||
<link rel="stylesheet" href="css/markers.css">
|
||||
<link rel="stylesheet" href="css/duty-list.css">
|
||||
<link rel="stylesheet" href="css/states.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="calendar-sticky" id="calendarSticky">
|
||||
<header class="header">
|
||||
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц">‹</button>
|
||||
<button type="button" class="nav" id="prevMonth" aria-label="">‹</button>
|
||||
<h1 class="title" id="monthTitle"></h1>
|
||||
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц">›</button>
|
||||
<button type="button" class="nav" id="nextMonth" aria-label="">›</button>
|
||||
</header>
|
||||
<div class="weekdays">
|
||||
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
|
||||
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
<div class="calendar" id="calendar"></div>
|
||||
</div>
|
||||
<div class="duty-list" id="dutyList"></div>
|
||||
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text">Загрузка…</span></div>
|
||||
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div>
|
||||
<div class="error" id="error" hidden></div>
|
||||
<div class="access-denied" id="accessDenied" hidden>
|
||||
<p>Доступ запрещён.</p>
|
||||
</div>
|
||||
<div class="access-denied" id="accessDenied" hidden></div>
|
||||
<div id="currentDutyView" class="current-duty-view hidden"></div>
|
||||
</div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"scopes": {
|
||||
"./js/": {
|
||||
"./js/i18n.js": "./js/i18n.js?v=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="js/main.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,16 +9,31 @@ import { t } from "./i18n.js";
|
||||
|
||||
/**
|
||||
* Build fetch options with init data header, Accept-Language and timeout abort.
|
||||
* Optional external signal (e.g. from loadMonth) aborts this request when triggered.
|
||||
* @param {string} initData - Telegram init data
|
||||
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
|
||||
* @param {AbortSignal} [externalSignal] - when aborted, cancels this request
|
||||
* @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }}
|
||||
*/
|
||||
export function buildFetchOptions(initData) {
|
||||
export function buildFetchOptions(initData, externalSignal) {
|
||||
const headers = {};
|
||||
if (initData) headers["X-Telegram-Init-Data"] = initData;
|
||||
headers["Accept-Language"] = state.lang || "ru";
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
return { headers, signal: controller.signal, timeoutId };
|
||||
const onAbort = () => controller.abort();
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
cleanup();
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
return { headers, signal: controller.signal, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,70 +41,69 @@ export function buildFetchOptions(initData) {
|
||||
* Caller checks res.ok, res.status, res.json().
|
||||
* @param {string} path - e.g. "/api/duties"
|
||||
* @param {{ from?: string, to?: string }} params - query params
|
||||
* @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation
|
||||
* @returns {Promise<Response>} - raw response
|
||||
*/
|
||||
export async function apiGet(path, params = {}) {
|
||||
export async function apiGet(path, params = {}, options = {}) {
|
||||
const base = window.location.origin;
|
||||
const query = new URLSearchParams(params).toString();
|
||||
const url = query ? `${base}${path}?${query}` : `${base}${path}`;
|
||||
const initData = getInitData();
|
||||
const opts = buildFetchOptions(initData);
|
||||
const opts = buildFetchOptions(initData, options.signal);
|
||||
try {
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
return res;
|
||||
} finally {
|
||||
clearTimeout(opts.timeoutId);
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch duties for date range. Throws ACCESS_DENIED error on 403.
|
||||
* AbortError is rethrown when the request is cancelled (e.g. stale loadMonth).
|
||||
* @param {string} from - YYYY-MM-DD
|
||||
* @param {string} to - YYYY-MM-DD
|
||||
* @param {AbortSignal} [signal] - optional signal to cancel the request
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function fetchDuties(from, to) {
|
||||
try {
|
||||
const res = await apiGet("/api/duties", { from, to });
|
||||
if (res.status === 403) {
|
||||
let detail = t(state.lang, "access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail !== undefined) {
|
||||
detail =
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: (body.detail.msg || JSON.stringify(body.detail));
|
||||
}
|
||||
} catch (parseErr) {
|
||||
/* ignore */
|
||||
export async function fetchDuties(from, to, signal) {
|
||||
const res = await apiGet("/api/duties", { from, to }, { signal });
|
||||
if (res.status === 403) {
|
||||
let detail = t(state.lang, "access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && body.detail !== undefined) {
|
||||
detail =
|
||||
typeof body.detail === "string"
|
||||
? body.detail
|
||||
: (body.detail.msg || JSON.stringify(body.detail));
|
||||
}
|
||||
const err = new Error("ACCESS_DENIED");
|
||||
err.serverDetail = detail;
|
||||
throw err;
|
||||
} catch (parseErr) {
|
||||
/* ignore */
|
||||
}
|
||||
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") {
|
||||
throw new Error(t(state.lang, "error_network"));
|
||||
}
|
||||
throw e;
|
||||
const err = new Error("ACCESS_DENIED");
|
||||
err.serverDetail = detail;
|
||||
throw err;
|
||||
}
|
||||
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403.
|
||||
* Rethrows AbortError when the request is cancelled (e.g. stale loadMonth).
|
||||
* @param {string} from - YYYY-MM-DD
|
||||
* @param {string} to - YYYY-MM-DD
|
||||
* @param {AbortSignal} [signal] - optional signal to cancel the request
|
||||
* @returns {Promise<object[]>}
|
||||
*/
|
||||
export async function fetchCalendarEvents(from, to) {
|
||||
export async function fetchCalendarEvents(from, to, signal) {
|
||||
try {
|
||||
const res = await apiGet("/api/calendar-events", { from, to });
|
||||
const res = await apiGet("/api/calendar-events", { from, to }, { signal });
|
||||
if (!res.ok) return [];
|
||||
return res.json();
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") throw e;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
212
webapp/js/api.test.js
Normal file
212
webapp/js/api.test.js
Normal 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
155
webapp/js/auth.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Calendar grid and events-by-date mapping.
|
||||
*/
|
||||
|
||||
import { calendarEl, monthTitleEl, state } from "./dom.js";
|
||||
import { getCalendarEl, getMonthTitleEl, state } from "./dom.js";
|
||||
import { monthName, t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import {
|
||||
@@ -29,6 +29,9 @@ export function calendarEventsByDate(events) {
|
||||
return byDate;
|
||||
}
|
||||
|
||||
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
|
||||
const MAX_DAYS_PER_DUTY = 366;
|
||||
|
||||
/**
|
||||
* Group duties by local date (start_at/end_at are UTC).
|
||||
* @param {object[]} duties - Duties with start_at, end_at
|
||||
@@ -39,14 +42,17 @@ export function dutiesByDate(duties) {
|
||||
duties.forEach((d) => {
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
if (end < start) return;
|
||||
const endLocal = localDateString(end);
|
||||
let t = new Date(start);
|
||||
while (true) {
|
||||
const key = localDateString(t);
|
||||
let cursor = new Date(start);
|
||||
let iterations = 0;
|
||||
while (iterations <= MAX_DAYS_PER_DUTY) {
|
||||
const key = localDateString(cursor);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
byDate[key].push(d);
|
||||
if (key === endLocal) break;
|
||||
t.setDate(t.getDate() + 1);
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
iterations++;
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
@@ -65,6 +71,8 @@ export function renderCalendar(
|
||||
dutiesByDateMap,
|
||||
calendarEventsByDateMap
|
||||
) {
|
||||
const calendarEl = getCalendarEl();
|
||||
const monthTitleEl = getMonthTitleEl();
|
||||
if (!calendarEl || !monthTitleEl) return;
|
||||
const first = firstDayOfMonth(new Date(year, month, 1));
|
||||
const last = lastDayOfMonth(new Date(year, month, 1));
|
||||
@@ -104,14 +112,8 @@ export function renderCalendar(
|
||||
start_at: x.start_at,
|
||||
end_at: x.end_at
|
||||
}));
|
||||
cell.setAttribute(
|
||||
"data-day-duties",
|
||||
JSON.stringify(dayPayload).replace(/"/g, """)
|
||||
);
|
||||
cell.setAttribute(
|
||||
"data-day-events",
|
||||
JSON.stringify(eventSummaries).replace(/"/g, """)
|
||||
);
|
||||
cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
|
||||
cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
|
||||
}
|
||||
|
||||
const ariaParts = [];
|
||||
|
||||
139
webapp/js/calendar.test.js
Normal file
139
webapp/js/calendar.test.js
Normal 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
150
webapp/js/contactHtml.js
Normal 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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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>";
|
||||
}
|
||||
176
webapp/js/contactHtml.test.js
Normal file
176
webapp/js/contactHtml.test.js
Normal 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(""");
|
||||
expect(html).toContain("<");
|
||||
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
234
webapp/js/currentDuty.js
Normal 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;
|
||||
}
|
||||
156
webapp/js/currentDuty.test.js
Normal file
156
webapp/js/currentDuty.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,16 +84,6 @@ export function dateKeyToDDMM(key) {
|
||||
return key.slice(8, 10) + "." + key.slice(5, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO date as HH:MM in local time.
|
||||
* @param {string} isoStr - ISO date string
|
||||
* @returns {string} HH:MM
|
||||
*/
|
||||
export function formatTimeLocal(isoStr) {
|
||||
const d = new Date(isoStr);
|
||||
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO string as HH:MM (local).
|
||||
* @param {string} isoStr - ISO date string
|
||||
|
||||
230
webapp/js/dateUtils.test.js
Normal file
230
webapp/js/dateUtils.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,10 @@
|
||||
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
|
||||
*/
|
||||
|
||||
import { calendarEl, state } from "./dom.js";
|
||||
import { getCalendarEl, state } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
|
||||
import { getDutyMarkerRows } from "./hints.js";
|
||||
|
||||
@@ -28,8 +29,7 @@ let sheetScrollY = 0;
|
||||
function parseDataAttr(raw) {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const s = raw.replace(/"/g, '"');
|
||||
const parsed = JSON.parse(s);
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
@@ -73,7 +73,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
|
||||
);
|
||||
const rows = hasTimes
|
||||
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
|
||||
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" }));
|
||||
: dutyList.map((it) => ({
|
||||
timePrefix: "",
|
||||
fullName: it.full_name || "",
|
||||
phone: it.phone,
|
||||
username: it.username
|
||||
}));
|
||||
|
||||
html +=
|
||||
'<section class="day-detail-section day-detail-section--duty">' +
|
||||
@@ -81,12 +86,21 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
|
||||
escapeHtml(t(lang, "event_type.duty")) +
|
||||
"</h3><ul class=" +
|
||||
'"day-detail-list">';
|
||||
rows.forEach((r) => {
|
||||
rows.forEach((r, i) => {
|
||||
const duty = hasTimes ? dutyList[i] : null;
|
||||
const phone = r.phone != null ? r.phone : (duty && duty.phone);
|
||||
const username = r.username != null ? r.username : (duty && duty.username);
|
||||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
|
||||
const contactHtml = buildContactLinksHtml(lang, phone, username, {
|
||||
classPrefix: "day-detail-contact",
|
||||
showLabels: true,
|
||||
separator: " "
|
||||
});
|
||||
html +=
|
||||
"<li>" +
|
||||
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
||||
escapeHtml(r.fullName) +
|
||||
(contactHtml ? contactHtml : "") +
|
||||
"</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
@@ -153,6 +167,7 @@ function positionPopover(panel, cellRect) {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
|
||||
let top = cellRect.bottom + 8;
|
||||
/* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */
|
||||
if (top + panelRect.height > vh - margin) {
|
||||
top = cellRect.top - panelRect.height - 8;
|
||||
panel.classList.add("day-detail-panel--below");
|
||||
@@ -210,6 +225,7 @@ function showAsPopover(cellRect) {
|
||||
const target = e.target instanceof Node ? e.target : null;
|
||||
if (!target || !panelEl) return;
|
||||
if (panelEl.contains(target)) return;
|
||||
const calendarEl = getCalendarEl();
|
||||
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
|
||||
hideDayDetail();
|
||||
};
|
||||
@@ -344,6 +360,7 @@ function ensurePanelInDom() {
|
||||
* Bind delegated click/keydown on calendar for .day cells.
|
||||
*/
|
||||
export function initDayDetail() {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return;
|
||||
calendarEl.addEventListener("click", (e) => {
|
||||
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);
|
||||
|
||||
@@ -38,4 +38,25 @@ describe("buildDayDetailContent", () => {
|
||||
const petrovPos = html.indexOf("Петров");
|
||||
expect(ivanovPos).toBeLessThan(petrovPos);
|
||||
});
|
||||
|
||||
it("includes contact info (phone, username) for duty entries when present", () => {
|
||||
const dateKey = "2025-03-01";
|
||||
const duties = [
|
||||
{
|
||||
event_type: "duty",
|
||||
full_name: "Alice",
|
||||
start_at: "2025-03-01T09:00:00",
|
||||
end_at: "2025-03-01T17:00:00",
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
},
|
||||
];
|
||||
const html = buildDayDetailContent(dateKey, duties, []);
|
||||
expect(html).toContain("Alice");
|
||||
expect(html).toContain("day-detail-contact-row");
|
||||
expect(html).toContain('href="tel:');
|
||||
expect(html).toContain("+79991234567");
|
||||
expect(html).toContain("https://t.me/");
|
||||
expect(html).toContain("alice_dev");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,62 @@
|
||||
/**
|
||||
* DOM references and shared application state.
|
||||
* Element refs are resolved lazily via getters so modules can be imported before DOM is ready.
|
||||
*/
|
||||
|
||||
/** @type {HTMLDivElement|null} */
|
||||
export const calendarEl = document.getElementById("calendar");
|
||||
/** @returns {HTMLDivElement|null} */
|
||||
export function getCalendarEl() {
|
||||
return document.getElementById("calendar");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const monthTitleEl = document.getElementById("monthTitle");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getMonthTitleEl() {
|
||||
return document.getElementById("monthTitle");
|
||||
}
|
||||
|
||||
/** @type {HTMLDivElement|null} */
|
||||
export const dutyListEl = document.getElementById("dutyList");
|
||||
/** @returns {HTMLDivElement|null} */
|
||||
export function getDutyListEl() {
|
||||
return document.getElementById("dutyList");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const loadingEl = document.getElementById("loading");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getLoadingEl() {
|
||||
return document.getElementById("loading");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const errorEl = document.getElementById("error");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getErrorEl() {
|
||||
return document.getElementById("error");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const accessDeniedEl = document.getElementById("accessDenied");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getAccessDeniedEl() {
|
||||
return document.getElementById("accessDenied");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const headerEl = document.querySelector(".header");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getHeaderEl() {
|
||||
return document.querySelector(".header");
|
||||
}
|
||||
|
||||
/** @type {HTMLElement|null} */
|
||||
export const weekdaysEl = document.querySelector(".weekdays");
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getWeekdaysEl() {
|
||||
return document.querySelector(".weekdays");
|
||||
}
|
||||
|
||||
/** @type {HTMLButtonElement|null} */
|
||||
export const prevBtn = document.getElementById("prevMonth");
|
||||
/** @returns {HTMLButtonElement|null} */
|
||||
export function getPrevBtn() {
|
||||
return document.getElementById("prevMonth");
|
||||
}
|
||||
|
||||
/** @type {HTMLButtonElement|null} */
|
||||
export const nextBtn = document.getElementById("nextMonth");
|
||||
/** @returns {HTMLButtonElement|null} */
|
||||
export function getNextBtn() {
|
||||
return document.getElementById("nextMonth");
|
||||
}
|
||||
|
||||
/** @returns {HTMLDivElement|null} */
|
||||
export function getCurrentDutyViewEl() {
|
||||
return document.getElementById("currentDutyView");
|
||||
}
|
||||
|
||||
/** Currently viewed month (mutable). */
|
||||
export const state = {
|
||||
@@ -41,5 +67,13 @@ export const state = {
|
||||
/** @type {ReturnType<typeof setInterval>|null} */
|
||||
todayRefreshInterval: null,
|
||||
/** @type {'ru'|'en'} */
|
||||
lang: "ru"
|
||||
lang: "ru",
|
||||
/** One-time bind flag for sticky scroll shadow listener. */
|
||||
stickyScrollBound: false,
|
||||
/** One-time bind flag for calendar (info button) hint document listeners. */
|
||||
calendarHintBound: false,
|
||||
/** One-time bind flag for duty marker hint document listeners. */
|
||||
dutyMarkerHintBound: false,
|
||||
/** Whether initData retry after ACCESS_DENIED has been attempted. */
|
||||
initDataRetried: false
|
||||
};
|
||||
|
||||
@@ -2,20 +2,31 @@
|
||||
* Duty list (timeline) rendering.
|
||||
*/
|
||||
|
||||
import { dutyListEl, state } from "./dom.js";
|
||||
import { getDutyListEl, state } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { buildContactLinksHtml } from "./contactHtml.js";
|
||||
import {
|
||||
localDateString,
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
dateKeyToDDMM,
|
||||
formatTimeLocal,
|
||||
formatHHMM,
|
||||
formatDateKey
|
||||
} from "./dateUtils.js";
|
||||
|
||||
/** Phone icon SVG for flip button (show contacts). */
|
||||
const ICON_PHONE =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
|
||||
|
||||
/** Back/arrow icon SVG for flip button (back to card). */
|
||||
const ICON_BACK =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
|
||||
|
||||
/**
|
||||
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM – HH:MM" or multi-day.
|
||||
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
|
||||
* Otherwise returns a plain card without flip wrapper.
|
||||
* @param {object} d - Duty
|
||||
* @param {boolean} isCurrent - Whether this is "current" duty
|
||||
* @returns {string}
|
||||
@@ -25,8 +36,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
||||
const endLocal = localDateString(new Date(d.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatTimeLocal(d.start_at);
|
||||
const endTime = formatTimeLocal(d.end_at);
|
||||
const startTime = formatHHMM(d.start_at);
|
||||
const endTime = formatHHMM(d.end_at);
|
||||
let timeStr;
|
||||
if (startLocal === endLocal) {
|
||||
timeStr = startDDMM + ", " + startTime + " – " + endTime;
|
||||
@@ -38,15 +49,66 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
||||
? t(lang, "duty.now_on_duty")
|
||||
: (t(lang, "event_type." + (d.event_type || "duty")));
|
||||
const extraClass = isCurrent ? " duty-item--current" : "";
|
||||
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
|
||||
classPrefix: "duty-contact",
|
||||
showLabels: false,
|
||||
separator: " · "
|
||||
});
|
||||
const hasContacts = Boolean(
|
||||
(d.phone && String(d.phone).trim()) ||
|
||||
(d.username && String(d.username).trim())
|
||||
);
|
||||
|
||||
if (!hasContacts) {
|
||||
return (
|
||||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
'"><span class="duty-item-type">' +
|
||||
escapeHtml(typeLabel) +
|
||||
'</span> <span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
'</span><div class="time">' +
|
||||
escapeHtml(timeStr) +
|
||||
"</div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
const showLabel = t(lang, "contact.show");
|
||||
const backLabel = t(lang, "contact.back");
|
||||
return (
|
||||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||||
'<div class="duty-flip-card' +
|
||||
extraClass +
|
||||
'"><span class="duty-item-type">' +
|
||||
'" data-flipped="false">' +
|
||||
'<div class="duty-flip-inner">' +
|
||||
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
'">' +
|
||||
'<span class="duty-item-type">' +
|
||||
escapeHtml(typeLabel) +
|
||||
'</span> <span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
'</span><div class="time">' +
|
||||
escapeHtml(timeStr) +
|
||||
'</div>' +
|
||||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||||
escapeHtml(showLabel) +
|
||||
'">' +
|
||||
ICON_PHONE +
|
||||
"</button>" +
|
||||
"</div>" +
|
||||
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
'">' +
|
||||
'<span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
"</span>" +
|
||||
contactHtml +
|
||||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||||
escapeHtml(backLabel) +
|
||||
'">' +
|
||||
ICON_BACK +
|
||||
"</button>" +
|
||||
"</div>" +
|
||||
"</div></div>"
|
||||
);
|
||||
}
|
||||
@@ -69,14 +131,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
||||
if (extraClass) itemClass += " " + extraClass;
|
||||
let timeOrRange = "";
|
||||
if (showUntilEnd && d.event_type === "duty") {
|
||||
timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
|
||||
timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) });
|
||||
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(d.start_at);
|
||||
const endStr = formatDateKey(d.end_at);
|
||||
timeOrRange = startStr === endStr ? startStr : startStr + " – " + endStr;
|
||||
} else {
|
||||
timeOrRange =
|
||||
formatTimeLocal(d.start_at) + " – " + formatTimeLocal(d.end_at);
|
||||
formatHHMM(d.start_at) + " – " + formatHHMM(d.end_at);
|
||||
}
|
||||
return (
|
||||
'<div class="' +
|
||||
@@ -86,17 +148,33 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
|
||||
'</span> <span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
'</span><div class="time">' +
|
||||
timeOrRange +
|
||||
escapeHtml(timeOrRange) +
|
||||
"</div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the delegated flip-button click listener has been attached to duty list element. */
|
||||
let flipListenerAttached = false;
|
||||
|
||||
/**
|
||||
* Render duty list (timeline) for current month; scroll to today if visible.
|
||||
* @param {object[]} duties - Duties (only duty type used for timeline)
|
||||
*/
|
||||
export function renderDutyList(duties) {
|
||||
const dutyListEl = getDutyListEl();
|
||||
if (!dutyListEl) return;
|
||||
|
||||
if (!flipListenerAttached) {
|
||||
flipListenerAttached = true;
|
||||
dutyListEl.addEventListener("click", (e) => {
|
||||
const btn = e.target.closest(".duty-flip-btn");
|
||||
if (!btn) return;
|
||||
const card = btn.closest(".duty-flip-card");
|
||||
if (!card) return;
|
||||
const flipped = card.getAttribute("data-flipped") === "true";
|
||||
card.setAttribute("data-flipped", String(!flipped));
|
||||
});
|
||||
}
|
||||
const filtered = duties.filter((d) => d.event_type === "duty");
|
||||
if (filtered.length === 0) {
|
||||
dutyListEl.classList.remove("duty-timeline");
|
||||
@@ -174,8 +252,9 @@ export function renderDutyList(duties) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
|
||||
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
|
||||
const listEl = getDutyListEl();
|
||||
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
|
||||
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
|
||||
if (currentDutyCard) {
|
||||
scrollToEl(currentDutyCard);
|
||||
} else if (todayBlock) {
|
||||
|
||||
164
webapp/js/dutyList.test.js
Normal file
164
webapp/js/dutyList.test.js
Normal 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("&");
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tooltips for calendar info buttons and duty markers.
|
||||
*/
|
||||
|
||||
import { calendarEl, state } from "./dom.js";
|
||||
import { getCalendarEl, state } from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
import { localDateString, formatHHMM } from "./dateUtils.js";
|
||||
@@ -250,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
|
||||
* Remove active class from all duty/unavailable/vacation markers.
|
||||
*/
|
||||
export function clearActiveDutyMarker() {
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return;
|
||||
calendarEl
|
||||
.querySelectorAll(
|
||||
@@ -258,148 +259,168 @@ export function clearActiveDutyMarker() {
|
||||
.forEach((m) => m.classList.remove("calendar-marker-active"));
|
||||
}
|
||||
|
||||
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
|
||||
let dutyMarkerHideTimeout = null;
|
||||
|
||||
const HINT_FADE_MS = 150;
|
||||
|
||||
/**
|
||||
* Bind click tooltips for .info-btn (calendar event summaries).
|
||||
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
|
||||
* @param {HTMLElement} hintEl - The hint element to dismiss
|
||||
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
|
||||
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
|
||||
*/
|
||||
export function bindInfoButtonTooltips() {
|
||||
let hintEl = document.getElementById("calendarEventHint");
|
||||
if (!hintEl) {
|
||||
hintEl = document.createElement("div");
|
||||
hintEl.id = "calendarEventHint";
|
||||
hintEl.className = "calendar-event-hint";
|
||||
hintEl.setAttribute("role", "tooltip");
|
||||
export function dismissHint(hintEl, opts = {}) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
const id = setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
document.body.appendChild(hintEl);
|
||||
}
|
||||
if (!calendarEl) return;
|
||||
const lang = state.lang;
|
||||
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const summary = btn.getAttribute("data-summary") || "";
|
||||
const content = t(lang, "hint.events") + "\n" + summary;
|
||||
if (hintEl.hidden || hintEl.textContent !== content) {
|
||||
hintEl.textContent = content;
|
||||
const rect = btn.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.dataset.active = "1";
|
||||
} else {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (!document._calendarHintBound) {
|
||||
document._calendarHintBound = true;
|
||||
document.addEventListener("click", () => {
|
||||
if (hintEl.dataset.active) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}, 150);
|
||||
}
|
||||
});
|
||||
hintEl.removeAttribute("data-active");
|
||||
if (opts.clearActive) clearActiveDutyMarker();
|
||||
if (typeof opts.afterHide === "function") opts.afterHide();
|
||||
}, HINT_FADE_MS);
|
||||
return id;
|
||||
}
|
||||
|
||||
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
|
||||
|
||||
/**
|
||||
* Get or create the calendar event (info button) hint element.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function getOrCreateCalendarEventHint() {
|
||||
let el = document.getElementById("calendarEventHint");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "calendarEventHint";
|
||||
el.className = "calendar-event-hint";
|
||||
el.setAttribute("role", "tooltip");
|
||||
el.hidden = true;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind hover/click tooltips for duty/unavailable/vacation markers.
|
||||
* Get or create the duty marker hint element.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function bindDutyMarkerTooltips() {
|
||||
let hintEl = document.getElementById("dutyMarkerHint");
|
||||
if (!hintEl) {
|
||||
hintEl = document.createElement("div");
|
||||
hintEl.id = "dutyMarkerHint";
|
||||
hintEl.className = "calendar-event-hint";
|
||||
hintEl.setAttribute("role", "tooltip");
|
||||
hintEl.hidden = true;
|
||||
document.body.appendChild(hintEl);
|
||||
function getOrCreateDutyMarkerHint() {
|
||||
let el = document.getElementById("dutyMarkerHint");
|
||||
if (!el) {
|
||||
el = document.createElement("div");
|
||||
el.id = "dutyMarkerHint";
|
||||
el.className = "calendar-event-hint";
|
||||
el.setAttribute("role", "tooltip");
|
||||
el.hidden = true;
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up event delegation on calendarEl for info button and duty marker tooltips.
|
||||
* Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render.
|
||||
*/
|
||||
export function initHints() {
|
||||
const calendarEventHint = getOrCreateCalendarEventHint();
|
||||
const dutyMarkerHint = getOrCreateDutyMarkerHint();
|
||||
const calendarEl = getCalendarEl();
|
||||
if (!calendarEl) return;
|
||||
let hideTimeout = null;
|
||||
const selector = ".duty-marker, .unavailable-marker, .vacation-marker";
|
||||
calendarEl.querySelectorAll(selector).forEach((marker) => {
|
||||
marker.addEventListener("mouseenter", () => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
const html = getDutyMarkerHintHtml(marker);
|
||||
if (html) {
|
||||
hintEl.innerHTML = html;
|
||||
|
||||
calendarEl.addEventListener("click", (e) => {
|
||||
const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
|
||||
if (btn) {
|
||||
e.stopPropagation();
|
||||
const summary = btn.getAttribute("data-summary") || "";
|
||||
const content = t(state.lang, "hint.events") + "\n" + summary;
|
||||
if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
|
||||
calendarEventHint.textContent = content;
|
||||
positionHint(calendarEventHint, btn.getBoundingClientRect());
|
||||
calendarEventHint.dataset.active = "1";
|
||||
} else {
|
||||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||||
dismissHint(calendarEventHint);
|
||||
}
|
||||
const rect = marker.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.hidden = false;
|
||||
});
|
||||
marker.addEventListener("mouseleave", () => {
|
||||
if (hintEl.dataset.active) return;
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
hideTimeout = setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hideTimeout = null;
|
||||
}, 150);
|
||||
});
|
||||
marker.addEventListener("click", (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
||||
if (marker) {
|
||||
e.stopPropagation();
|
||||
if (marker.classList.contains("calendar-marker-active")) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
}, 150);
|
||||
dismissHint(dutyMarkerHint);
|
||||
marker.classList.remove("calendar-marker-active");
|
||||
return;
|
||||
}
|
||||
clearActiveDutyMarker();
|
||||
const html = getDutyMarkerHintHtml(marker);
|
||||
if (html) {
|
||||
hintEl.innerHTML = html;
|
||||
dutyMarkerHint.innerHTML = html;
|
||||
} else {
|
||||
hintEl.textContent = getDutyMarkerHintContent(marker);
|
||||
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
|
||||
}
|
||||
const rect = marker.getBoundingClientRect();
|
||||
positionHint(hintEl, rect);
|
||||
hintEl.hidden = false;
|
||||
hintEl.dataset.active = "1";
|
||||
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
|
||||
dutyMarkerHint.hidden = false;
|
||||
dutyMarkerHint.dataset.active = "1";
|
||||
marker.classList.add("calendar-marker-active");
|
||||
}
|
||||
});
|
||||
|
||||
calendarEl.addEventListener("mouseover", (e) => {
|
||||
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
||||
if (!marker) return;
|
||||
const related = e.relatedTarget instanceof Node ? e.relatedTarget : null;
|
||||
if (related && marker.contains(related)) return;
|
||||
if (dutyMarkerHideTimeout) {
|
||||
clearTimeout(dutyMarkerHideTimeout);
|
||||
dutyMarkerHideTimeout = null;
|
||||
}
|
||||
const html = getDutyMarkerHintHtml(marker);
|
||||
if (html) {
|
||||
dutyMarkerHint.innerHTML = html;
|
||||
} else {
|
||||
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
|
||||
}
|
||||
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
|
||||
dutyMarkerHint.hidden = false;
|
||||
});
|
||||
|
||||
calendarEl.addEventListener("mouseout", (e) => {
|
||||
const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
|
||||
if (!fromMarker) return;
|
||||
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
|
||||
if (toMarker) return;
|
||||
if (dutyMarkerHint.dataset.active) return;
|
||||
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
|
||||
afterHide: () => {
|
||||
dutyMarkerHideTimeout = null;
|
||||
},
|
||||
});
|
||||
});
|
||||
if (!document._dutyMarkerHintBound) {
|
||||
document._dutyMarkerHintBound = true;
|
||||
|
||||
if (!state.calendarHintBound) {
|
||||
state.calendarHintBound = true;
|
||||
document.addEventListener("click", () => {
|
||||
if (hintEl.dataset.active) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
clearActiveDutyMarker();
|
||||
}, 150);
|
||||
if (calendarEventHint.dataset.active) {
|
||||
dismissHint(calendarEventHint);
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && hintEl.dataset.active) {
|
||||
hintEl.classList.remove("calendar-event-hint--visible");
|
||||
setTimeout(() => {
|
||||
hintEl.hidden = true;
|
||||
hintEl.removeAttribute("data-active");
|
||||
clearActiveDutyMarker();
|
||||
}, 150);
|
||||
if (e.key === "Escape" && calendarEventHint.dataset.active) {
|
||||
dismissHint(calendarEventHint);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!state.dutyMarkerHintBound) {
|
||||
state.dutyMarkerHintBound = true;
|
||||
document.addEventListener("click", () => {
|
||||
if (dutyMarkerHint.dataset.active) {
|
||||
dismissHint(dutyMarkerHint, { clearActive: true });
|
||||
}
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
|
||||
dismissHint(dutyMarkerHint, { clearActive: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
|
||||
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
|
||||
* Also tests dismissHint helper.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { getDutyMarkerRows } from "./hints.js";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
|
||||
import { getDutyMarkerRows, dismissHint } from "./hints.js";
|
||||
|
||||
const FROM = "from";
|
||||
const TO = "until";
|
||||
@@ -124,3 +125,52 @@ describe("getDutyMarkerRows", () => {
|
||||
expect(rows[2].timePrefix).toContain("15:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dismissHint", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("removes visible class immediately and hides element after delay", () => {
|
||||
const el = document.createElement("div");
|
||||
el.classList.add("calendar-event-hint--visible");
|
||||
el.hidden = false;
|
||||
el.setAttribute("data-active", "1");
|
||||
|
||||
dismissHint(el);
|
||||
|
||||
expect(el.classList.contains("calendar-event-hint--visible")).toBe(false);
|
||||
expect(el.hidden).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(150);
|
||||
|
||||
expect(el.hidden).toBe(true);
|
||||
expect(el.hasAttribute("data-active")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns timeout id usable with clearTimeout", () => {
|
||||
const el = document.createElement("div");
|
||||
const id = dismissHint(el);
|
||||
expect(id).toBeDefined();
|
||||
clearTimeout(id);
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(el.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it("calls afterHide callback after delay when provided", () => {
|
||||
const el = document.createElement("div");
|
||||
let called = false;
|
||||
dismissHint(el, {
|
||||
afterHide: () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
vi.advanceTimersByTime(150);
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +49,17 @@ export const MESSAGES = {
|
||||
"hint.to": "until",
|
||||
"hint.duty_title": "Duty:",
|
||||
"hint.events": "Events:",
|
||||
"day_detail.close": "Close"
|
||||
"day_detail.close": "Close",
|
||||
"contact.label": "Contact",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"contact.phone": "Phone",
|
||||
"contact.telegram": "Telegram",
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar"
|
||||
},
|
||||
ru: {
|
||||
"app.title": "Календарь дежурств",
|
||||
@@ -94,7 +104,17 @@ export const MESSAGES = {
|
||||
"hint.to": "до",
|
||||
"hint.duty_title": "Дежурство:",
|
||||
"hint.events": "События:",
|
||||
"day_detail.close": "Закрыть"
|
||||
"day_detail.close": "Закрыть",
|
||||
"contact.label": "Контакт",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"contact.phone": "Телефон",
|
||||
"contact.telegram": "Telegram",
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
87
webapp/js/i18n.test.js
Normal file
87
webapp/js/i18n.test.js
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -4,17 +4,16 @@
|
||||
|
||||
import { initTheme, applyTheme } from "./theme.js";
|
||||
import { getLang, t, weekdayLabels } from "./i18n.js";
|
||||
import { getInitData } from "./auth.js";
|
||||
import { isLocalhost } from "./auth.js";
|
||||
import { getInitData, isLocalhost } from "./auth.js";
|
||||
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
|
||||
import {
|
||||
state,
|
||||
accessDeniedEl,
|
||||
prevBtn,
|
||||
nextBtn,
|
||||
loadingEl,
|
||||
errorEl,
|
||||
weekdaysEl
|
||||
getAccessDeniedEl,
|
||||
getPrevBtn,
|
||||
getNextBtn,
|
||||
getLoadingEl,
|
||||
getErrorEl,
|
||||
getWeekdaysEl
|
||||
} from "./dom.js";
|
||||
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
|
||||
import { fetchDuties, fetchCalendarEvents } from "./api.js";
|
||||
@@ -24,7 +23,9 @@ import {
|
||||
renderCalendar
|
||||
} from "./calendar.js";
|
||||
import { initDayDetail } from "./dayDetail.js";
|
||||
import { initHints } from "./hints.js";
|
||||
import { renderDutyList } from "./dutyList.js";
|
||||
import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
@@ -38,15 +39,19 @@ initTheme();
|
||||
state.lang = getLang();
|
||||
document.documentElement.lang = state.lang;
|
||||
document.title = t(state.lang, "app.title");
|
||||
const loadingEl = getLoadingEl();
|
||||
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
|
||||
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
|
||||
const dayLabels = weekdayLabels(state.lang);
|
||||
const weekdaysEl = getWeekdaysEl();
|
||||
if (weekdaysEl) {
|
||||
const spans = weekdaysEl.querySelectorAll("span");
|
||||
spans.forEach((span, i) => {
|
||||
if (dayLabels[i]) span.textContent = dayLabels[i];
|
||||
});
|
||||
}
|
||||
const prevBtn = getPrevBtn();
|
||||
const nextBtn = getNextBtn();
|
||||
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
|
||||
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
|
||||
|
||||
@@ -98,21 +103,33 @@ function requireTelegramOrLocalhost(onAllowed) {
|
||||
return;
|
||||
}
|
||||
showAccessDenied(undefined);
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
const loading = getLoadingEl();
|
||||
if (loading) loading.classList.add("hidden");
|
||||
}, RETRY_DELAY_MS);
|
||||
return;
|
||||
}
|
||||
showAccessDenied(undefined);
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
const loading = getLoadingEl();
|
||||
if (loading) loading.classList.add("hidden");
|
||||
}
|
||||
|
||||
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
|
||||
let loadMonthAbortController = null;
|
||||
|
||||
/**
|
||||
* Load current month: fetch duties and events, render calendar and duty list.
|
||||
* Stale requests are cancelled when the user navigates to another month before they complete.
|
||||
*/
|
||||
async function loadMonth() {
|
||||
if (loadMonthAbortController) loadMonthAbortController.abort();
|
||||
loadMonthAbortController = new AbortController();
|
||||
const signal = loadMonthAbortController.signal;
|
||||
|
||||
hideAccessDenied();
|
||||
setNavEnabled(false);
|
||||
const loadingEl = getLoadingEl();
|
||||
if (loadingEl) loadingEl.classList.remove("hidden");
|
||||
const errorEl = getErrorEl();
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
const current = state.current;
|
||||
const first = firstDayOfMonth(current);
|
||||
@@ -122,8 +139,8 @@ async function loadMonth() {
|
||||
const from = localDateString(start);
|
||||
const to = localDateString(gridEnd);
|
||||
try {
|
||||
const dutiesPromise = fetchDuties(from, to);
|
||||
const eventsPromise = fetchCalendarEvents(from, to);
|
||||
const dutiesPromise = fetchDuties(from, to, signal);
|
||||
const eventsPromise = fetchCalendarEvents(from, to, signal);
|
||||
const duties = await dutiesPromise;
|
||||
const events = await eventsPromise;
|
||||
const byDate = dutiesByDate(duties);
|
||||
@@ -156,15 +173,18 @@ async function loadMonth() {
|
||||
}, 60000);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
if (e.message === "ACCESS_DENIED") {
|
||||
showAccessDenied(e.serverDetail);
|
||||
setNavEnabled(true);
|
||||
if (
|
||||
window.Telegram &&
|
||||
window.Telegram.WebApp &&
|
||||
!window._initDataRetried
|
||||
!state.initDataRetried
|
||||
) {
|
||||
window._initDataRetried = true;
|
||||
state.initDataRetried = true;
|
||||
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
|
||||
}
|
||||
return;
|
||||
@@ -173,21 +193,26 @@ async function loadMonth() {
|
||||
setNavEnabled(true);
|
||||
return;
|
||||
}
|
||||
if (loadingEl) loadingEl.classList.add("hidden");
|
||||
const loading = getLoadingEl();
|
||||
if (loading) loading.classList.add("hidden");
|
||||
setNavEnabled(true);
|
||||
}
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener("click", () => {
|
||||
const prevBtnEl = getPrevBtn();
|
||||
if (prevBtnEl) {
|
||||
prevBtnEl.addEventListener("click", () => {
|
||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||
const accessDeniedEl = getAccessDeniedEl();
|
||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||
state.current.setMonth(state.current.getMonth() - 1);
|
||||
loadMonth();
|
||||
});
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener("click", () => {
|
||||
const nextBtnEl = getNextBtn();
|
||||
if (nextBtnEl) {
|
||||
nextBtnEl.addEventListener("click", () => {
|
||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||
const accessDeniedEl = getAccessDeniedEl();
|
||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||
state.current.setMonth(state.current.getMonth() + 1);
|
||||
loadMonth();
|
||||
@@ -204,9 +229,9 @@ if (nextBtn) {
|
||||
"touchstart",
|
||||
(e) => {
|
||||
if (e.changedTouches.length === 0) return;
|
||||
const t = e.changedTouches[0];
|
||||
startX = t.clientX;
|
||||
startY = t.clientY;
|
||||
const touch = e.changedTouches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
@@ -215,12 +240,15 @@ if (nextBtn) {
|
||||
(e) => {
|
||||
if (e.changedTouches.length === 0) return;
|
||||
if (document.body.classList.contains("day-detail-sheet-open")) return;
|
||||
const accessDeniedEl = getAccessDeniedEl();
|
||||
if (accessDeniedEl && !accessDeniedEl.hidden) return;
|
||||
const t = e.changedTouches[0];
|
||||
const deltaX = t.clientX - startX;
|
||||
const deltaY = t.clientY - startY;
|
||||
const touch = e.changedTouches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
|
||||
const prevBtn = getPrevBtn();
|
||||
const nextBtn = getNextBtn();
|
||||
if (deltaX > SWIPE_THRESHOLD) {
|
||||
if (prevBtn && prevBtn.disabled) return;
|
||||
state.current.setMonth(state.current.getMonth() - 1);
|
||||
@@ -237,8 +265,8 @@ if (nextBtn) {
|
||||
|
||||
function bindStickyScrollShadow() {
|
||||
const stickyEl = document.getElementById("calendarSticky");
|
||||
if (!stickyEl || document._stickyScrollBound) return;
|
||||
document._stickyScrollBound = true;
|
||||
if (!stickyEl || state.stickyScrollBound) return;
|
||||
state.stickyScrollBound = true;
|
||||
function updateScrolled() {
|
||||
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
|
||||
}
|
||||
@@ -250,6 +278,19 @@ runWhenReady(() => {
|
||||
requireTelegramOrLocalhost(() => {
|
||||
bindStickyScrollShadow();
|
||||
initDayDetail();
|
||||
loadMonth();
|
||||
initHints();
|
||||
const startParam =
|
||||
(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe &&
|
||||
window.Telegram.WebApp.initDataUnsafe.start_param) ||
|
||||
"";
|
||||
if (startParam === "duty") {
|
||||
state.lang = getLang();
|
||||
showCurrentDutyView(() => {
|
||||
hideCurrentDutyView();
|
||||
loadMonth();
|
||||
});
|
||||
} else {
|
||||
loadMonth();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
152
webapp/js/theme.test.js
Normal file
152
webapp/js/theme.test.js
Normal 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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
import {
|
||||
state,
|
||||
calendarEl,
|
||||
dutyListEl,
|
||||
loadingEl,
|
||||
errorEl,
|
||||
accessDeniedEl,
|
||||
headerEl,
|
||||
weekdaysEl,
|
||||
prevBtn,
|
||||
nextBtn
|
||||
getCalendarEl,
|
||||
getDutyListEl,
|
||||
getLoadingEl,
|
||||
getErrorEl,
|
||||
getAccessDeniedEl,
|
||||
getHeaderEl,
|
||||
getWeekdaysEl,
|
||||
getPrevBtn,
|
||||
getNextBtn
|
||||
} from "./dom.js";
|
||||
import { t } from "./i18n.js";
|
||||
|
||||
@@ -21,6 +21,13 @@ import { t } from "./i18n.js";
|
||||
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
|
||||
*/
|
||||
export function showAccessDenied(serverDetail) {
|
||||
const headerEl = getHeaderEl();
|
||||
const weekdaysEl = getWeekdaysEl();
|
||||
const calendarEl = getCalendarEl();
|
||||
const dutyListEl = getDutyListEl();
|
||||
const loadingEl = getLoadingEl();
|
||||
const errorEl = getErrorEl();
|
||||
const accessDeniedEl = getAccessDeniedEl();
|
||||
if (headerEl) headerEl.hidden = true;
|
||||
if (weekdaysEl) weekdaysEl.hidden = true;
|
||||
if (calendarEl) calendarEl.hidden = true;
|
||||
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
|
||||
* Hide access-denied and show calendar/list/header/weekdays.
|
||||
*/
|
||||
export function hideAccessDenied() {
|
||||
const accessDeniedEl = getAccessDeniedEl();
|
||||
const headerEl = getHeaderEl();
|
||||
const weekdaysEl = getWeekdaysEl();
|
||||
const calendarEl = getCalendarEl();
|
||||
const dutyListEl = getDutyListEl();
|
||||
if (accessDeniedEl) accessDeniedEl.hidden = true;
|
||||
if (headerEl) headerEl.hidden = false;
|
||||
if (weekdaysEl) weekdaysEl.hidden = false;
|
||||
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
|
||||
* @param {string} msg - Error text
|
||||
*/
|
||||
export function showError(msg) {
|
||||
const errorEl = getErrorEl();
|
||||
const loadingEl = getLoadingEl();
|
||||
if (errorEl) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.hidden = false;
|
||||
@@ -68,6 +82,8 @@ export function showError(msg) {
|
||||
* @param {boolean} enabled
|
||||
*/
|
||||
export function setNavEnabled(enabled) {
|
||||
const prevBtn = getPrevBtn();
|
||||
const nextBtn = getNextBtn();
|
||||
if (prevBtn) prevBtn.disabled = !enabled;
|
||||
if (nextBtn) nextBtn.disabled = !enabled;
|
||||
}
|
||||
|
||||
122
webapp/js/ui.test.js
Normal file
122
webapp/js/ui.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,19 @@
|
||||
* Common utilities.
|
||||
*/
|
||||
|
||||
const ESCAPE_MAP = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape string for safe use in HTML (text content / attributes).
|
||||
* @param {string} s - Raw string
|
||||
* @returns {string} HTML-escaped string
|
||||
*/
|
||||
export function escapeHtml(s) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = s;
|
||||
return div.innerHTML;
|
||||
return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
|
||||
}
|
||||
|
||||
42
webapp/js/utils.test.js
Normal file
42
webapp/js/utils.test.js
Normal 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 & b");
|
||||
});
|
||||
|
||||
it("escapes less-than and greater-than", () => {
|
||||
expect(escapeHtml("<script>")).toBe("<script>");
|
||||
});
|
||||
|
||||
it("escapes double quote", () => {
|
||||
expect(escapeHtml('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
it("escapes single quote", () => {
|
||||
expect(escapeHtml("it's")).toBe("it's");
|
||||
});
|
||||
|
||||
it("escapes all special chars together", () => {
|
||||
expect(escapeHtml('&<>"\'')).toBe("&<>"'");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
882
webapp/style.css
882
webapp/style.css
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user