diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index 9770b16..1361288 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -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) diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index ee8cc89..835adeb 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -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 ] diff --git a/duty_teller/config.py b/duty_teller/config.py index d9a4987..574bcc6 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -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,13 @@ 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 +128,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 diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index 2dee3d3..d6fe68c 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -317,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. @@ -326,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) ) @@ -343,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"). @@ -357,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 = [ @@ -368,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) ) diff --git a/duty_teller/db/schemas.py b/duty_teller/db/schemas.py index fb55e8c..fdd943e 100644 --- a/duty_teller/db/schemas.py +++ b/duty_teller/db/schemas.py @@ -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) diff --git a/duty_teller/handlers/group_duty_pin.py b/duty_teller/handlers/group_duty_pin.py index c53da0e..ca9e8e8 100644 --- a/duty_teller/handlers/group_duty_pin.py +++ b/duty_teller/handlers/group_duty_pin.py @@ -6,7 +6,7 @@ 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 @@ -81,6 +81,23 @@ def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool: 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. @@ -180,7 +197,11 @@ async def _refresh_pin_for_chat( 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 @@ -260,7 +281,11 @@ async def my_chat_member_handler( 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 @@ -334,7 +359,11 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No 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 @@ -422,7 +451,11 @@ async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> 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 after trust_group chat_id=%s: %s", diff --git a/duty_teller/i18n/messages.py b/duty_teller/i18n/messages.py index e14a566..ca485da 100644 --- a/duty_teller/i18n/messages.py +++ b/duty_teller/i18n/messages.py @@ -60,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.", @@ -87,6 +88,12 @@ 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.back": "Back to calendar", }, "ru": { "start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.", @@ -142,6 +149,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": "Доступ только для администраторов.", @@ -162,5 +170,11 @@ 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.back": "Назад к календарю", }, } diff --git a/duty_teller/run.py b/duty_teller/run.py index dcb730f..9d7913e 100644 --- a/duty_teller/run.py +++ b/duty_teller/run.py @@ -11,6 +11,14 @@ from telegram.ext import ApplicationBuilder from duty_teller import config from duty_teller.config import require_bot_token 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 logging.basicConfig( @@ -69,6 +77,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) diff --git a/duty_teller/services/group_duty_pin_service.py b/duty_teller/services/group_duty_pin_service.py index b824660..48d4fdc 100644 --- a/duty_teller/services/group_duty_pin_service.py +++ b/duty_teller/services/group_duty_pin_service.py @@ -85,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) diff --git a/tests/test_api_dependencies.py b/tests/test_api_dependencies.py index b27cd64..e68a2fd 100644 --- a/tests/test_api_dependencies.py +++ b/tests/test_api_dependencies.py @@ -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" diff --git a/tests/test_app.py b/tests/test_app.py index c479418..1281f15 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/tests/test_group_duty_pin_service.py b/tests/test_group_duty_pin_service.py index 7dc8b53..a2935d0 100644 --- a/tests/test_group_duty_pin_service.py +++ b/tests/test_group_duty_pin_service.py @@ -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: diff --git a/tests/test_handlers_group_duty_pin.py b/tests/test_handlers_group_duty_pin.py index ccfb7ae..33db262 100644 --- a/tests/test_handlers_group_duty_pin.py +++ b/tests/test_handlers_group_duty_pin.py @@ -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.""" @@ -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, "_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") + 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 @@ -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, "logger") as mock_logger: 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_logger.warning.assert_called_once() 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: 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") + 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 @@ -409,7 +446,9 @@ async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_rep ) 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") + 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 ) @@ -485,7 +524,9 @@ async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not ) 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") + 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") @@ -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, "_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") + 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 ) @@ -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, "_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") + 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 ) @@ -920,7 +965,9 @@ async def test_trust_group_cmd_admin_adds_group(): 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") + 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 ) diff --git a/tests/test_import_duty_schedule_integration.py b/tests/test_import_duty_schedule_integration.py index 7b16bb5..dd8c034 100644 --- a/tests/test_import_duty_schedule_integration.py +++ b/tests/test_import_duty_schedule_integration.py @@ -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" diff --git a/webapp/index.html b/webapp/index.html index c98e2f3..578acf0 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -24,8 +24,18 @@
+ - + + diff --git a/webapp/js/currentDuty.js b/webapp/js/currentDuty.js new file mode 100644 index 0000000..27f774c --- /dev/null +++ b/webapp/js/currentDuty.js @@ -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, "&").replace(/"/g, """).replace(/' + + escapeHtml(label) + + ": " + + '' + + escapeHtml(p) + + "" + ); + } + 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( + '' + + escapeHtml(label) + + ": " + + '' + + escapeHtml(display) + + "" + ); + } + } + return parts.length + ? '
' + parts.join(" ") + "
" + : ""; +} + +/** + * 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 ( + '
' + + '

' + + escapeHtml(title) + + "

" + + '

' + + escapeHtml(noDuty) + + "

" + + '" + + "
" + ); + } + + 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 ( + '
' + + '

' + + escapeHtml(title) + + "

" + + '

' + + escapeHtml(duty.full_name) + + "

" + + '
' + + escapeHtml(shiftLabel) + + ": " + + escapeHtml(shiftStr) + + "
" + + contactHtml + + '" + + "
" + ); +} + +/** + * 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 = + '
' + + escapeHtml(t(lang, "loading")) + + "
"; + + 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 = + '
' + + '

' + + escapeHtml(e.message || t(lang, "error_generic")) + + "

" + + '" + + "
"; + } + + 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; +} diff --git a/webapp/js/currentDuty.test.js b/webapp/js/currentDuty.test.js new file mode 100644 index 0000000..df4e641 --- /dev/null +++ b/webapp/js/currentDuty.test.js @@ -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 = + '
' + + '
' + + '
' + + '
' + + '' + + "
"; + }); + + 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); + }); + }); +}); diff --git a/webapp/js/dayDetail.js b/webapp/js/dayDetail.js index 5c68aee..821c2f2 100644 --- a/webapp/js/dayDetail.js +++ b/webapp/js/dayDetail.js @@ -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, "&").replace(/"/g, """).replace(/' + + escapeHtml(label) + ": " + + '' + + escapeHtml(p) + "" + ); + } + 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( + '' + + escapeHtml(label) + ": " + + '' + + escapeHtml(display) + "" + ); + } + } + return parts.length ? '
' + parts.join(" ") + "
" : ""; +} + /** * Build HTML content for the day detail panel. * @param {string} dateKey - YYYY-MM-DD @@ -72,7 +109,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 += '
' + @@ -80,12 +122,17 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) { escapeHtml(t(lang, "event_type.duty")) + "
"; diff --git a/webapp/js/dayDetail.test.js b/webapp/js/dayDetail.test.js index d6b5870..1278771 100644 --- a/webapp/js/dayDetail.test.js +++ b/webapp/js/dayDetail.test.js @@ -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"); + }); }); diff --git a/webapp/js/dom.js b/webapp/js/dom.js index 778ba3a..4ca66ed 100644 --- a/webapp/js/dom.js +++ b/webapp/js/dom.js @@ -32,6 +32,9 @@ export const prevBtn = document.getElementById("prevMonth"); /** @type {HTMLButtonElement|null} */ export const nextBtn = document.getElementById("nextMonth"); +/** @type {HTMLDivElement|null} */ +export const currentDutyViewEl = document.getElementById("currentDutyView"); + /** Currently viewed month (mutable). */ export const state = { /** @type {Date} */ diff --git a/webapp/js/dutyList.js b/webapp/js/dutyList.js index f35b13e..a43c69c 100644 --- a/webapp/js/dutyList.js +++ b/webapp/js/dutyList.js @@ -14,8 +14,49 @@ import { formatDateKey } 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, "&").replace(/"/g, """).replace(/' + + escapeHtml(p) + "" + ); + } + 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( + '@' + + escapeHtml(u) + "" + ); + } + } + return parts.length + ? '
' + parts.join(" · ") + "
" + : ""; +} + +/** Phone icon SVG for flip button (show contacts). */ +const ICON_PHONE = + ''; + +/** Back/arrow icon SVG for flip button (back to card). */ +const ICON_BACK = + ''; + /** * 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} @@ -38,15 +79,62 @@ 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 = dutyCardContactHtml(lang, d); + const hasContacts = Boolean( + (d.phone && String(d.phone).trim()) || + (d.username && String(d.username).trim()) + ); + + if (!hasContacts) { + return ( + '
' + + escapeHtml(typeLabel) + + ' ' + + escapeHtml(d.full_name) + + '
' + + escapeHtml(timeStr) + + "
" + ); + } + + const showLabel = t(lang, "contact.show"); + const backLabel = t(lang, "contact.back"); return ( - '
' + + '" data-flipped="false">' + + '
' + + '
' + + '' + escapeHtml(typeLabel) + ' ' + escapeHtml(d.full_name) + '
' + escapeHtml(timeStr) + + '
' + + '" + + "
" + + '
' + + '' + + escapeHtml(d.full_name) + + "" + + contactHtml + + '" + + "
" + "
" ); } @@ -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. * @param {object[]} duties - Duties (only duty type used for timeline) */ export function renderDutyList(duties) { 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"); diff --git a/webapp/js/dutyList.test.js b/webapp/js/dutyList.test.js new file mode 100644 index 0000000..2ee5ba5 --- /dev/null +++ b/webapp/js/dutyList.test.js @@ -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 = + '
' + + '
' + + '
' + + ''; + }); + + 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"); + }); + }); +}); diff --git a/webapp/js/i18n.js b/webapp/js/i18n.js index 1bb75af..90abbbb 100644 --- a/webapp/js/i18n.js +++ b/webapp/js/i18n.js @@ -49,7 +49,16 @@ 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.back": "Back to calendar" }, ru: { "app.title": "Календарь дежурств", @@ -94,7 +103,16 @@ 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.back": "Назад к календарю" } }; diff --git a/webapp/js/main.js b/webapp/js/main.js index 24dfd4a..03c7f54 100644 --- a/webapp/js/main.js +++ b/webapp/js/main.js @@ -25,6 +25,7 @@ import { import { initDayDetail } from "./dayDetail.js"; import { initHints } from "./hints.js"; import { renderDutyList } from "./dutyList.js"; +import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js"; import { firstDayOfMonth, lastDayOfMonth, @@ -262,6 +263,18 @@ runWhenReady(() => { bindStickyScrollShadow(); initDayDetail(); 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(); + } }); }); diff --git a/webapp/style.css b/webapp/style.css index 7bd9c33..f64a6d5 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -416,6 +416,48 @@ body.day-detail-sheet-open { 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; @@ -746,6 +788,70 @@ body.day-detail-sheet-open { 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-list .duty-item { 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 .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)); @@ -848,10 +989,115 @@ body.day-detail-sheet-open { color: var(--error); } -.error[hidden], .loading.hidden { +.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; + 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 { text-align: center; padding: 24px 12px;