feat: enhance duty information handling with contact details and current duty view

- Added `bot_username` to settings for dynamic retrieval of the bot's username.
- Implemented `_resolve_bot_username` function to fetch the bot's username if not set, improving user experience in group chats.
- Updated `DutyWithUser` schema to include optional `phone` and `username` fields for enhanced duty information.
- Enhanced API responses to include contact details for users, ensuring better communication.
- Introduced a new current duty view in the web app, displaying active duty information along with contact options.
- Updated CSS styles for better presentation of contact information in duty cards.
- Added unit tests to verify the inclusion of contact details in API responses and the functionality of the current duty view.
This commit is contained in:
2026-03-02 16:09:08 +03:00
parent f8aceabab5
commit e3240d0981
25 changed files with 1126 additions and 44 deletions

View File

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

View File

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

View File

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

View File

@@ -317,8 +317,8 @@ def get_duties(
session: Session, session: Session,
from_date: str, from_date: str,
to_date: str, to_date: str,
) -> list[tuple[Duty, str]]: ) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties overlapping the given date range with user full_name. """Return duties overlapping the given date range with user full_name, phone, username.
Args: Args:
session: DB session. session: DB session.
@@ -326,11 +326,11 @@ def get_duties(
to_date: End date YYYY-MM-DD. to_date: End date YYYY-MM-DD.
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name, phone, username) tuples.
""" """
to_date_next = to_date_exclusive_iso(to_date) to_date_next = to_date_exclusive_iso(to_date)
q = ( q = (
session.query(Duty, User.full_name) session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id) .join(User, Duty.user_id == User.id)
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date) .filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
) )
@@ -343,7 +343,7 @@ def get_duties_for_user(
from_date: str, from_date: str,
to_date: str, to_date: str,
event_types: list[str] | None = None, event_types: list[str] | None = None,
) -> list[tuple[Duty, str]]: ) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties for one user overlapping the date range. """Return duties for one user overlapping the date range.
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation"). Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
@@ -357,7 +357,7 @@ def get_duties_for_user(
event_types: If not None, only return duties whose event_type is in this list. event_types: If not None, only return duties whose event_type is in this list.
Returns: Returns:
List of (Duty, full_name) tuples. List of (Duty, full_name, phone, username) tuples.
""" """
to_date_next = to_date_exclusive_iso(to_date) to_date_next = to_date_exclusive_iso(to_date)
filters = [ filters = [
@@ -368,7 +368,7 @@ def get_duties_for_user(
if event_types is not None: if event_types is not None:
filters.append(Duty.event_type.in_(event_types)) filters.append(Duty.event_type.in_(event_types))
q = ( q = (
session.query(Duty, User.full_name) session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id) .join(User, Duty.user_id == User.id)
.filter(*filters) .filter(*filters)
) )

View File

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

View File

@@ -6,7 +6,7 @@ from datetime import datetime, timezone
from typing import Literal from typing import Literal
import duty_teller.config as config import duty_teller.config as config
from telegram import Update from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatMemberStatus from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
@@ -81,6 +81,23 @@ def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
return False 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]: def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
"""Remove group from trusted list. """Remove group from trusted list.
@@ -180,7 +197,11 @@ async def _refresh_pin_for_chat(
return "no_message" return "no_message"
old_message_id = message_id old_message_id = message_id
try: try:
msg = await context.bot.send_message(chat_id=chat_id, text=text) msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
)
except (BadRequest, Forbidden) as e: except (BadRequest, Forbidden) as e:
logger.warning( logger.warning(
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e "Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
@@ -260,7 +281,11 @@ async def my_chat_member_handler(
None, lambda: _get_duty_message_text_sync(lang) None, lambda: _get_duty_message_text_sync(lang)
) )
try: try:
msg = await context.bot.send_message(chat_id=chat_id, text=text) msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e: except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e) logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return return
@@ -334,7 +359,11 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
None, lambda: _get_duty_message_text_sync(lang) None, lambda: _get_duty_message_text_sync(lang)
) )
try: try:
msg = await context.bot.send_message(chat_id=chat_id, text=text) msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e: except (BadRequest, Forbidden) as e:
logger.warning( logger.warning(
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e "Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
@@ -422,7 +451,11 @@ async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
None, lambda: _get_duty_message_text_sync(lang) None, lambda: _get_duty_message_text_sync(lang)
) )
try: try:
msg = await context.bot.send_message(chat_id=chat_id, text=text) msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e: except (BadRequest, Forbidden) as e:
logger.warning( logger.warning(
"Failed to send duty message after trust_group chat_id=%s: %s", "Failed to send duty message after trust_group chat_id=%s: %s",

View File

@@ -60,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"administrator with «Pin messages» permission, then send /pin_duty in the " "administrator with «Pin messages» permission, then send /pin_duty in the "
"chat — the current message will be pinned." "chat — the current message will be pinned."
), ),
"pin_duty.view_contacts": "View contacts",
"duty.no_duty": "No duty at the moment.", "duty.no_duty": "No duty at the moment.",
"duty.label": "Duty:", "duty.label": "Duty:",
"import.admin_only": "Access for administrators only.", "import.admin_only": "Access for administrators only.",
@@ -87,6 +88,12 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Access denied", "api.access_denied": "Access denied",
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format", "dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
"dates.from_after_to": "from date must not be after to", "dates.from_after_to": "from date must not be after to",
"contact.show": "Contacts",
"contact.back": "Back",
"current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift",
"current_duty.back": "Back to calendar",
}, },
"ru": { "ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.", "start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -142,6 +149,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. " "pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), " "Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.", "затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
"pin_duty.view_contacts": "Контакты",
"duty.no_duty": "Сейчас дежурства нет.", "duty.no_duty": "Сейчас дежурства нет.",
"duty.label": "Дежурство:", "duty.label": "Дежурство:",
"import.admin_only": "Доступ только для администраторов.", "import.admin_only": "Доступ только для администраторов.",
@@ -162,5 +170,11 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Доступ запрещён", "api.access_denied": "Доступ запрещён",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD", "dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to", "dates.from_after_to": "Дата from не должна быть позже to",
"contact.show": "Контакты",
"contact.back": "Назад",
"current_duty.title": "Текущее дежурство",
"current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена",
"current_duty.back": "Назад к календарю",
}, },
} }

View File

@@ -11,6 +11,14 @@ from telegram.ext import ApplicationBuilder
from duty_teller import config from duty_teller import config
from duty_teller.config import require_bot_token from duty_teller.config import require_bot_token
from duty_teller.handlers import group_duty_pin, register_handlers from duty_teller.handlers import group_duty_pin, register_handlers
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)
from duty_teller.utils.http_client import safe_urlopen from duty_teller.utils.http_client import safe_urlopen
logging.basicConfig( logging.basicConfig(
@@ -69,6 +77,7 @@ def main() -> None:
ApplicationBuilder() ApplicationBuilder()
.token(config.BOT_TOKEN) .token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs) .post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build() .build()
) )
register_handlers(app) register_handlers(app)

View File

@@ -85,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
f"🕐 {label} {time_range}", f"🕐 {label} {time_range}",
f"👤 {user.full_name}", f"👤 {user.full_name}",
] ]
if user.phone:
lines.append(f"📞 {user.phone}")
if user.username:
lines.append(f"@{user.username}")
return "\n".join(lines) return "\n".join(lines)

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,13 @@ import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod from duty_teller.handlers import group_duty_pin as mod
@pytest.fixture(autouse=True)
def no_mini_app_url():
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
with patch.object(config, "BOT_USERNAME", ""):
yield
class TestSyncWrappers: class TestSyncWrappers:
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync.""" """Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
@@ -76,6 +83,30 @@ class TestSyncWrappers:
# --- _schedule_next_update --- # --- _schedule_next_update ---
def test_get_contact_button_markup_empty_username_returns_none():
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
with patch.object(config, "BOT_USERNAME", ""):
assert mod._get_contact_button_markup("en") is None
def test_get_contact_button_markup_returns_markup_when_username_set():
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
from telegram import InlineKeyboardMarkup
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
with patch.object(mod, "t", return_value="View contacts"):
result = mod._get_contact_button_markup("en")
assert result is not None
assert isinstance(result, InlineKeyboardMarkup)
assert len(result.inline_keyboard) == 1
assert len(result.inline_keyboard[0]) == 1
btn = result.inline_keyboard[0][0]
assert btn.text == "View contacts"
assert btn.url.startswith("https://t.me/")
assert "startapp=duty" in btn.url
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_schedule_next_update_job_queue_none_returns_early(): async def test_schedule_next_update_job_queue_none_returns_early():
"""_schedule_next_update: job_queue is None -> log and return, no run_once.""" """_schedule_next_update: job_queue is None -> log and return, no run_once."""
@@ -151,7 +182,9 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
with patch.object(mod, "_schedule_next_update", AsyncMock()): with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context) await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty") context.bot.send_message.assert_called_once_with(
chat_id=123, text="Current duty", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123) context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=123, message_id=999, disable_notification=False chat_id=123, message_id=999, disable_notification=False
@@ -260,7 +293,9 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger: with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context) await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text") context.bot.send_message.assert_called_once_with(
chat_id=222, text="Text", reply_markup=None
)
mock_save.assert_not_called() mock_save.assert_not_called()
mock_logger.warning.assert_called_once() mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0] assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
@@ -292,7 +327,9 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
) as mock_schedule: ) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save: with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context) await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text") context.bot.send_message.assert_called_once_with(
chat_id=333, text="Text", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333) context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=333, message_id=777, disable_notification=True chat_id=333, message_id=777, disable_notification=True
@@ -409,7 +446,9 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep
) as mock_t: ) as mock_t:
mock_t.return_value = "Pinned" mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context) await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=42, disable_notification=True chat_id=100, message_id=42, disable_notification=True
) )
@@ -485,7 +524,9 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not
) as mock_t: ) as mock_t:
mock_t.return_value = "Make me admin to pin" mock_t.return_value = "Make me admin to pin"
await mod.pin_duty_cmd(update, context) await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty") context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty", reply_markup=None
)
mock_save.assert_called_once_with(100, 43) mock_save.assert_called_once_with(100, 43)
update.message.reply_text.assert_called_once_with("Make me admin to pin") update.message.reply_text.assert_called_once_with("Make me admin to pin")
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin") mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
@@ -689,7 +730,9 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()): with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context) await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text") context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True chat_id=200, message_id=42, disable_notification=True
) )
@@ -744,7 +787,9 @@ async def test_my_chat_member_handler_trusted_group_sends_duty():
with patch.object(mod, "_get_next_shift_end_sync", return_value=None): with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()): with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context) await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text") context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True chat_id=200, message_id=42, disable_notification=True
) )
@@ -920,7 +965,9 @@ async def test_trust_group_cmd_admin_adds_group():
await mod.trust_group_cmd(update, context) await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_any_call("Added") update.message.reply_text.assert_any_call("Added")
mock_t.assert_any_call("en", "trust_group.added") mock_t.assert_any_call("en", "trust_group.added")
context.bot.send_message.assert_called_once_with(chat_id=100, text="Duty text") context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with( context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=50, disable_notification=True chat_id=100, message_id=50, disable_notification=True
) )

View File

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

View File

@@ -24,8 +24,18 @@
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div> <div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div>
<div class="error" id="error" hidden></div> <div class="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden></div> <div class="access-denied" id="accessDenied" hidden></div>
<div id="currentDutyView" class="current-duty-view hidden"></div>
</div> </div>
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script type="module" src="js/main.js"></script> <script type="importmap">
{
"scopes": {
"./js/": {
"./js/i18n.js": "./js/i18n.js?v=1"
}
}
}
</script>
<script type="module" src="js/main.js?v=4"></script>
</body> </body>
</html> </html>

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

@@ -0,0 +1,239 @@
/**
* 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 { currentDutyViewEl, state, loadingEl } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { fetchDuties } from "./api.js";
import {
localDateString,
dateKeyToDDMM,
formatHHMM
} from "./dateUtils.js";
/**
* 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;
}
/**
* Build contact HTML (phone + Telegram) for current duty card, styled like day-detail.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function buildContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const label = t(lang, "contact.phone");
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
safeHref +
'" class="current-duty-contact-link current-duty-contact-phone">' +
escapeHtml(p) +
"</a></span>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="current-duty-contact">' +
escapeHtml(label) +
": " +
'<a href="' +
escapeHtml(href) +
'" class="current-duty-contact-link current-duty-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a></span>"
);
}
}
return parts.length
? '<div class="current-duty-contact-row">' + parts.join(" ") + "</div>"
: "";
}
/**
* 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">' +
'<h2 class="current-duty-title">' +
escapeHtml(title) +
"</h2>" +
'<p class="current-duty-no-duty">' +
escapeHtml(noDuty) +
"</p>" +
'<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 contactHtml = buildContactHtml(lang, duty);
return (
'<div class="current-duty-card">' +
'<h2 class="current-duty-title">' +
escapeHtml(title) +
"</h2>" +
'<p class="current-duty-name">' +
escapeHtml(duty.full_name) +
"</p>" +
'<div class="current-duty-shift">' +
escapeHtml(shiftLabel) +
": " +
escapeHtml(shiftStr) +
"</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 container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (!currentDutyViewEl) return;
currentDutyViewEl._onBack = onBack;
currentDutyViewEl.classList.remove("hidden");
if (container) container.setAttribute("data-view", "currentDuty");
if (calendarSticky) calendarSticky.hidden = true;
if (dutyList) dutyList.hidden = true;
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 (currentDutyViewEl._onBack) currentDutyViewEl._onBack();
};
currentDutyViewEl._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 (currentDutyViewEl && currentDutyViewEl._onBack) {
currentDutyViewEl._onBack();
}
}
/**
* 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 container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
const backHandler = currentDutyViewEl && currentDutyViewEl._backButtonHandler;
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
if (backHandler) {
window.Telegram.WebApp.BackButton.offClick(backHandler);
}
window.Telegram.WebApp.BackButton.hide();
}
if (currentDutyViewEl) {
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
currentDutyViewEl._onBack = null;
currentDutyViewEl._backButtonHandler = null;
currentDutyViewEl.classList.add("hidden");
currentDutyViewEl.innerHTML = "";
}
if (container) container.removeAttribute("data-view");
if (calendarSticky) calendarSticky.hidden = false;
if (dutyList) dutyList.hidden = false;
}

View File

@@ -0,0 +1,121 @@
/**
* 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,
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("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");
expect(html).toContain("No one is on duty right now");
expect(html).toContain("Back to calendar");
expect(html).toContain('data-action="back"');
});
it("renders duty card with name, shift, 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("Текущее дежурство");
expect(html).toContain("Иванов Иван");
expect(html).toContain("Смена");
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-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+7 900 123-45-67");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("Back to calendar");
});
});
describe("showCurrentDutyView", () => {
it("hides the global loading element when called", async () => {
vi.resetModules();
const { showCurrentDutyView } = await import("./currentDuty.js");
await showCurrentDutyView(() => {});
const loading = document.getElementById("loading");
expect(loading).not.toBeNull();
expect(loading.classList.contains("hidden")).toBe(true);
});
});
});

View File

@@ -35,6 +35,43 @@ function parseDataAttr(raw) {
} }
} }
/**
* Build HTML for contact info (phone link, Telegram username link) for a duty entry.
* @param {'ru'|'en'} lang
* @param {string|null|undefined} phone
* @param {string|null|undefined} username - Telegram username with or without leading @
* @returns {string}
*/
function buildContactHtml(lang, phone, username) {
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const label = t(lang, "contact.phone");
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + safeHref + '" class="day-detail-contact-link day-detail-contact-phone">' +
escapeHtml(p) + "</a></span>"
);
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const label = t(lang, "contact.telegram");
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<span class="day-detail-contact">' +
escapeHtml(label) + ": " +
'<a href="' + escapeHtml(href) + '" class="day-detail-contact-link day-detail-contact-username" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) + "</a></span>"
);
}
}
return parts.length ? '<div class="day-detail-contact-row">' + parts.join(" ") + "</div>" : "";
}
/** /**
* Build HTML content for the day detail panel. * Build HTML content for the day detail panel.
* @param {string} dateKey - YYYY-MM-DD * @param {string} dateKey - YYYY-MM-DD
@@ -72,7 +109,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
); );
const rows = hasTimes const rows = hasTimes
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel) ? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" })); : dutyList.map((it) => ({
timePrefix: "",
fullName: it.full_name || "",
phone: it.phone,
username: it.username
}));
html += html +=
'<section class="day-detail-section day-detail-section--duty">' + '<section class="day-detail-section day-detail-section--duty">' +
@@ -80,12 +122,17 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
escapeHtml(t(lang, "event_type.duty")) + escapeHtml(t(lang, "event_type.duty")) +
"</h3><ul class=" + "</h3><ul class=" +
'"day-detail-list">'; '"day-detail-list">';
rows.forEach((r) => { rows.forEach((r, i) => {
const duty = hasTimes ? dutyList[i] : null;
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : ""; const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactHtml(lang, phone, username);
html += html +=
"<li>" + "<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") + (timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) + escapeHtml(r.fullName) +
(contactHtml ? contactHtml : "") +
"</li>"; "</li>";
}); });
html += "</ul></section>"; html += "</ul></section>";

View File

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

View File

@@ -32,6 +32,9 @@ export const prevBtn = document.getElementById("prevMonth");
/** @type {HTMLButtonElement|null} */ /** @type {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth"); export const nextBtn = document.getElementById("nextMonth");
/** @type {HTMLDivElement|null} */
export const currentDutyViewEl = document.getElementById("currentDutyView");
/** Currently viewed month (mutable). */ /** Currently viewed month (mutable). */
export const state = { export const state = {
/** @type {Date} */ /** @type {Date} */

View File

@@ -14,8 +14,49 @@ import {
formatDateKey formatDateKey
} from "./dateUtils.js"; } from "./dateUtils.js";
/**
* Build HTML for contact links (phone, Telegram) for a duty. Returns empty string if none.
* @param {'ru'|'en'} lang
* @param {object} d - Duty with optional phone, username
* @returns {string}
*/
function dutyCardContactHtml(lang, d) {
const parts = [];
if (d.phone && String(d.phone).trim()) {
const p = String(d.phone).trim();
const safeHref = "tel:" + p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
parts.push(
'<a href="' + safeHref + '" class="duty-contact-link duty-contact-phone">' +
escapeHtml(p) + "</a>"
);
}
if (d.username && String(d.username).trim()) {
const u = String(d.username).trim().replace(/^@+/, "");
if (u) {
const href = "https://t.me/" + encodeURIComponent(u);
parts.push(
'<a href="' + href.replace(/"/g, "&quot;") + '" class="duty-contact-link duty-contact-username" target="_blank" rel="noopener noreferrer">@' +
escapeHtml(u) + "</a>"
);
}
}
return parts.length
? '<div class="duty-contact-row">' + parts.join(" · ") + "</div>"
: "";
}
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Back/arrow icon SVG for flip button (back to card). */
const ICON_BACK =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
/** /**
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM HH:MM" or multi-day. * Build HTML for one timeline duty card: one-day "DD.MM, HH:MM HH:MM" or multi-day.
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
* Otherwise returns a plain card without flip wrapper.
* @param {object} d - Duty * @param {object} d - Duty
* @param {boolean} isCurrent - Whether this is "current" duty * @param {boolean} isCurrent - Whether this is "current" duty
* @returns {string} * @returns {string}
@@ -38,15 +79,62 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty") ? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty"))); : (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : ""; const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = dutyCardContactHtml(lang, d);
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
);
if (!hasContacts) {
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
extraClass +
'"><span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
"</div></div>"
);
}
const showLabel = t(lang, "contact.show");
const backLabel = t(lang, "contact.back");
return ( return (
'<div class="duty-item duty-item--duty duty-timeline-card' + '<div class="duty-flip-card' +
extraClass + extraClass +
'"><span class="duty-item-type">' + '" data-flipped="false">' +
'<div class="duty-flip-inner">' +
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="duty-item-type">' +
escapeHtml(typeLabel) + escapeHtml(typeLabel) +
'</span> <span class="name">' + '</span> <span class="name">' +
escapeHtml(d.full_name) + escapeHtml(d.full_name) +
'</span><div class="time">' + '</span><div class="time">' +
escapeHtml(timeStr) + escapeHtml(timeStr) +
'</div>' +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(showLabel) +
'">' +
ICON_PHONE +
"</button>" +
"</div>" +
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="name">' +
escapeHtml(d.full_name) +
"</span>" +
contactHtml +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(backLabel) +
'">' +
ICON_BACK +
"</button>" +
"</div>" +
"</div></div>" "</div></div>"
); );
} }
@@ -91,12 +179,27 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
); );
} }
/** Whether the delegated flip-button click listener has been attached to dutyListEl. */
let flipListenerAttached = false;
/** /**
* Render duty list (timeline) for current month; scroll to today if visible. * Render duty list (timeline) for current month; scroll to today if visible.
* @param {object[]} duties - Duties (only duty type used for timeline) * @param {object[]} duties - Duties (only duty type used for timeline)
*/ */
export function renderDutyList(duties) { export function renderDutyList(duties) {
if (!dutyListEl) return; if (!dutyListEl) return;
if (!flipListenerAttached) {
flipListenerAttached = true;
dutyListEl.addEventListener("click", (e) => {
const btn = e.target.closest(".duty-flip-btn");
if (!btn) return;
const card = btn.closest(".duty-flip-card");
if (!card) return;
const flipped = card.getAttribute("data-flipped") === "true";
card.setAttribute("data-flipped", String(!flipped));
});
}
const filtered = duties.filter((d) => d.event_type === "duty"); const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) { if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline"); dutyListEl.classList.remove("duty-timeline");

View File

@@ -0,0 +1,92 @@
/**
* Unit tests for dutyList (dutyTimelineCardHtml, contact rendering).
*/
import { describe, it, expect, beforeAll } from "vitest";
import { dutyTimelineCardHtml } 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");
});
});
});

View File

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

View File

@@ -25,6 +25,7 @@ import {
import { initDayDetail } from "./dayDetail.js"; import { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js"; import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js"; import { renderDutyList } from "./dutyList.js";
import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js";
import { import {
firstDayOfMonth, firstDayOfMonth,
lastDayOfMonth, lastDayOfMonth,
@@ -262,6 +263,18 @@ runWhenReady(() => {
bindStickyScrollShadow(); bindStickyScrollShadow();
initDayDetail(); initDayDetail();
initHints(); initHints();
loadMonth(); 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();
}
}); });
}); });

View File

@@ -416,6 +416,48 @@ body.day-detail-sheet-open {
color: var(--muted); 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 { .info-btn {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -746,6 +788,70 @@ body.day-detail-sheet-open {
min-width: 0; min-width: 0;
} }
/* Flip-card: front = duty info + button, back = contacts */
.duty-flip-card {
perspective: 600px;
position: relative;
min-height: 0;
}
.duty-flip-inner {
transition: transform 0.4s;
transform-style: preserve-3d;
position: relative;
min-height: 0;
}
.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-timeline-card.duty-item,
.duty-list .duty-item { .duty-list .duty-item {
display: grid; display: grid;
@@ -793,6 +899,41 @@ body.day-detail-sheet-open {
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; } .duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; } .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 { .duty-item--current {
border-left-color: var(--today); border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface)); background: color-mix(in srgb, var(--today) 12%, var(--surface));
@@ -848,10 +989,115 @@ body.day-detail-sheet-open {
color: var(--error); color: var(--error);
} }
.error[hidden], .loading.hidden { .error[hidden], .loading.hidden,
.current-duty-view.hidden {
display: none !important; 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;
padding: 24px;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.current-duty-title {
margin: 0 0 16px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.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-no-duty,
.current-duty-error {
margin: 0 0 16px 0;
color: var(--muted);
}
.current-duty-error {
color: var(--error);
}
.current-duty-contact-row {
margin: 12px 0 20px 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 { .access-denied {
text-align: center; text-align: center;
padding: 24px 12px; padding: 24px 12px;