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:
@@ -5,6 +5,8 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
@@ -56,6 +58,22 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/app/") and (
|
||||
path.endswith(".js") or path.endswith(".html")
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
@@ -126,7 +144,9 @@ def get_team_calendar_ical(
|
||||
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
|
||||
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
duties_duty_only = [
|
||||
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
|
||||
(d, name)
|
||||
for d, name, *_ in all_duties
|
||||
if (d.event_type or "duty") == "duty"
|
||||
]
|
||||
ics_bytes = build_team_ics(duties_duty_only)
|
||||
ics_calendar_cache.set(cache_key, ics_bytes)
|
||||
|
||||
@@ -190,7 +190,7 @@ def fetch_duties_response(
|
||||
to_date: End date YYYY-MM-DD.
|
||||
|
||||
Returns:
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
|
||||
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
|
||||
"""
|
||||
rows = get_duties(session, from_date=from_date, to_date=to_date)
|
||||
return [
|
||||
@@ -203,6 +203,8 @@ def fetch_duties_response(
|
||||
event_type=(
|
||||
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
|
||||
),
|
||||
phone=phone,
|
||||
username=username,
|
||||
)
|
||||
for duty, full_name in rows
|
||||
for duty, full_name, phone, username in rows
|
||||
]
|
||||
|
||||
@@ -52,6 +52,7 @@ class Settings:
|
||||
|
||||
bot_token: str
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
@@ -93,9 +94,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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Назад к календарю",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -71,3 +71,31 @@ class TestValidateDutyDates:
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == "From after to message"
|
||||
mock_t.assert_called_with("ru", "dates.from_after_to")
|
||||
|
||||
|
||||
class TestFetchDutiesResponse:
|
||||
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
|
||||
|
||||
def test_fetch_duties_response_includes_phone_and_username(self):
|
||||
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from duty_teller.db.schemas import DutyWithUser
|
||||
|
||||
duty = SimpleNamespace(
|
||||
id=1,
|
||||
user_id=10,
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
|
||||
with patch.object(deps, "get_duties", return_value=rows):
|
||||
result = deps.fetch_duties_response(
|
||||
type("Session", (), {})(), "2025-01-01", "2025-01-31"
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert isinstance(result[0], DutyWithUser)
|
||||
assert result[0].full_name == "Alice"
|
||||
assert result[0].phone == "+79001234567"
|
||||
assert result[0].username == "alice_dev"
|
||||
|
||||
@@ -254,7 +254,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
)
|
||||
|
||||
def fake_get_duties(session, from_date, to_date):
|
||||
return [(fake_duty, "User A")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
return [(fake_duty, "User A", "+79001234567", "user_a")]
|
||||
|
||||
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
|
||||
r = client.get(
|
||||
@@ -266,6 +267,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
assert len(data) == 1
|
||||
assert data[0]["event_type"] == "duty"
|
||||
assert data[0]["full_name"] == "User A"
|
||||
assert data[0].get("phone") == "+79001234567"
|
||||
assert data[0].get("username") == "user_a"
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
@@ -311,7 +314,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
end_at="2026-06-16T18:00:00Z",
|
||||
event_type="vacation",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
|
||||
# get_duties returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [
|
||||
(duty, "User A", None, None),
|
||||
(non_duty, "User B", None, None),
|
||||
]
|
||||
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
|
||||
token = "y" * 43
|
||||
|
||||
@@ -371,7 +378,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = (
|
||||
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
|
||||
)
|
||||
@@ -415,7 +423,8 @@ def test_calendar_ical_ignores_unknown_query_params(
|
||||
end_at="2026-06-15T18:00:00Z",
|
||||
event_type="duty",
|
||||
)
|
||||
mock_get_duties.return_value = [(duty, "User A")]
|
||||
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
|
||||
mock_get_duties.return_value = [(duty, "User A", None, None)]
|
||||
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
|
||||
token = "z" * 43
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
|
||||
assert result == "No duty"
|
||||
|
||||
def test_with_duty_and_user_returns_formatted(self):
|
||||
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
|
||||
duty = SimpleNamespace(
|
||||
start_at="2025-01-15T09:00:00Z",
|
||||
end_at="2025-01-15T18:00:00Z",
|
||||
@@ -94,9 +95,10 @@ class TestFormatDutyMessage:
|
||||
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
|
||||
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
|
||||
assert "Иван Иванов" in result
|
||||
assert "+79001234567" in result or "79001234567" in result
|
||||
assert "@ivan" in result
|
||||
assert "Duty" in result
|
||||
# Contact info is restricted to Mini App; not shown in pinned group message.
|
||||
assert "+79001234567" not in result and "79001234567" not in result
|
||||
assert "@ivan" not in result
|
||||
|
||||
|
||||
class TestGetDutyMessageText:
|
||||
|
||||
@@ -11,6 +11,13 @@ import duty_teller.config as config
|
||||
from duty_teller.handlers import group_duty_pin as mod
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_mini_app_url():
|
||||
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
yield
|
||||
|
||||
|
||||
class TestSyncWrappers:
|
||||
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
|
||||
|
||||
@@ -76,6 +83,30 @@ class TestSyncWrappers:
|
||||
# --- _schedule_next_update ---
|
||||
|
||||
|
||||
def test_get_contact_button_markup_empty_username_returns_none():
|
||||
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
|
||||
with patch.object(config, "BOT_USERNAME", ""):
|
||||
assert mod._get_contact_button_markup("en") is None
|
||||
|
||||
|
||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
assert len(result.inline_keyboard) == 1
|
||||
assert len(result.inline_keyboard[0]) == 1
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.text == "View contacts"
|
||||
assert btn.url.startswith("https://t.me/")
|
||||
assert "startapp=duty" in btn.url
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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="error" id="error" hidden></div>
|
||||
<div class="access-denied" id="accessDenied" hidden></div>
|
||||
<div id="currentDutyView" class="current-duty-view hidden"></div>
|
||||
</div>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<script type="module" src="js/main.js"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"scopes": {
|
||||
"./js/": {
|
||||
"./js/i18n.js": "./js/i18n.js?v=1"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="js/main.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
239
webapp/js/currentDuty.js
Normal file
239
webapp/js/currentDuty.js
Normal 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, "&").replace(/"/g, """).replace(/</g, "<");
|
||||
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;
|
||||
}
|
||||
121
webapp/js/currentDuty.test.js
Normal file
121
webapp/js/currentDuty.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(/</g, "<");
|
||||
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.
|
||||
* @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 +=
|
||||
'<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")) +
|
||||
"</h3><ul class=" +
|
||||
'"day-detail-list">';
|
||||
rows.forEach((r) => {
|
||||
rows.forEach((r, i) => {
|
||||
const duty = hasTimes ? dutyList[i] : null;
|
||||
const phone = r.phone != null ? r.phone : (duty && duty.phone);
|
||||
const username = r.username != null ? r.username : (duty && duty.username);
|
||||
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
|
||||
const contactHtml = buildContactHtml(lang, phone, username);
|
||||
html +=
|
||||
"<li>" +
|
||||
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
|
||||
escapeHtml(r.fullName) +
|
||||
(contactHtml ? contactHtml : "") +
|
||||
"</li>";
|
||||
});
|
||||
html += "</ul></section>";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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} */
|
||||
|
||||
@@ -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(/</g, "<");
|
||||
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, """) + '" 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.
|
||||
* 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,6 +79,13 @@ 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 (
|
||||
'<div class="duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
@@ -51,6 +99,46 @@ export function dutyTimelineCardHtml(d, isCurrent) {
|
||||
);
|
||||
}
|
||||
|
||||
const showLabel = t(lang, "contact.show");
|
||||
const backLabel = t(lang, "contact.back");
|
||||
return (
|
||||
'<div class="duty-flip-card' +
|
||||
extraClass +
|
||||
'" data-flipped="false">' +
|
||||
'<div class="duty-flip-inner">' +
|
||||
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
'">' +
|
||||
'<span class="duty-item-type">' +
|
||||
escapeHtml(typeLabel) +
|
||||
'</span> <span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
'</span><div class="time">' +
|
||||
escapeHtml(timeStr) +
|
||||
'</div>' +
|
||||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||||
escapeHtml(showLabel) +
|
||||
'">' +
|
||||
ICON_PHONE +
|
||||
"</button>" +
|
||||
"</div>" +
|
||||
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
|
||||
extraClass +
|
||||
'">' +
|
||||
'<span class="name">' +
|
||||
escapeHtml(d.full_name) +
|
||||
"</span>" +
|
||||
contactHtml +
|
||||
'<button class="duty-flip-btn" type="button" aria-label="' +
|
||||
escapeHtml(backLabel) +
|
||||
'">' +
|
||||
ICON_BACK +
|
||||
"</button>" +
|
||||
"</div>" +
|
||||
"</div></div>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML for one duty card.
|
||||
* @param {object} d - Duty
|
||||
@@ -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");
|
||||
|
||||
92
webapp/js/dutyList.test.js
Normal file
92
webapp/js/dutyList.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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": "Назад к календарю"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
248
webapp/style.css
248
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;
|
||||
|
||||
Reference in New Issue
Block a user