diff --git a/.env.example b/.env.example index 4e5afec..3021a24 100644 --- a/.env.example +++ b/.env.example @@ -7,6 +7,10 @@ HTTP_PORT=8080 ALLOWED_USERNAMES=username1,username2 ADMIN_USERNAMES=admin1,admin2 +# Optional: allow by phone (user sets phone via /set_phone in bot). Comma-separated; normalized to digits for comparison. +# ALLOWED_PHONES=79001234567,79007654321 +# ADMIN_PHONES=79001111111 + # Dev only: set to 1 to allow calendar without Telegram initData (insecure; do not use in production). # MINI_APP_SKIP_AUTH=1 diff --git a/duty_teller/api/dependencies.py b/duty_teller/api/dependencies.py index 7641ea3..f5643eb 100644 --- a/duty_teller/api/dependencies.py +++ b/duty_teller/api/dependencies.py @@ -4,12 +4,12 @@ import logging import re from typing import Annotated, Generator -from fastapi import Header, HTTPException, Query, Request +from fastapi import Depends, Header, HTTPException, Query, Request from sqlalchemy.orm import Session import duty_teller.config as config from duty_teller.api.telegram_auth import validate_init_data_with_reason -from duty_teller.db.repository import get_duties +from duty_teller.db.repository import get_duties, get_user_by_telegram_id from duty_teller.db.schemas import DutyWithUser from duty_teller.db.session import session_scope from duty_teller.i18n import t @@ -74,8 +74,9 @@ def require_miniapp_username( x_telegram_init_data: Annotated[ str | None, Header(alias="X-Telegram-Init-Data") ] = None, + session: Session = Depends(get_db_session), ) -> str: - return get_authenticated_username(request, x_telegram_init_data) + return get_authenticated_username(request, x_telegram_init_data, session) def _is_private_client(client_host: str | None) -> bool: @@ -95,31 +96,43 @@ def _is_private_client(client_host: str | None) -> bool: def get_authenticated_username( - request: Request, x_telegram_init_data: str | None + request: Request, + x_telegram_init_data: str | None, + session: Session, ) -> str: + """Return identifier for miniapp auth (username or full_name or id:...); empty if skip-auth.""" + if config.MINI_APP_SKIP_AUTH: + log.warning("allowing without any auth check (MINI_APP_SKIP_AUTH is set)") + return "" init_data = (x_telegram_init_data or "").strip() if not init_data: client_host = request.client.host if request.client else None - if _is_private_client(client_host) or config.MINI_APP_SKIP_AUTH: - if config.MINI_APP_SKIP_AUTH: - log.warning("allowing without initData (MINI_APP_SKIP_AUTH is set)") + if _is_private_client(client_host): return "" log.warning("no X-Telegram-Init-Data header (client=%s)", client_host) lang = _lang_from_accept_language(request.headers.get("Accept-Language")) raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram")) max_age = config.INIT_DATA_MAX_AGE_SECONDS or None - username, auth_reason, lang = validate_init_data_with_reason( + telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason( init_data, config.BOT_TOKEN, max_age_seconds=max_age ) - if username is None: + if auth_reason != "ok": log.warning("initData validation failed: %s", auth_reason) raise HTTPException( status_code=403, detail=_auth_error_detail(auth_reason, lang) ) - if not config.can_access_miniapp(username): - log.warning("username not in allowlist: %s", username) - raise HTTPException(status_code=403, detail=t(lang, "api.access_denied")) - return username + if username and config.can_access_miniapp(username): + return username + if telegram_user_id is not None: + user = get_user_by_telegram_id(session, telegram_user_id) + if user and user.phone and config.can_access_miniapp_by_phone(user.phone): + return username or (user.full_name or "") or f"id:{telegram_user_id}" + log.warning( + "username/phone not in allowlist (username=%s, telegram_id=%s)", + username, + telegram_user_id, + ) + raise HTTPException(status_code=403, detail=t(lang, "api.access_denied")) def fetch_duties_response( diff --git a/duty_teller/api/telegram_auth.py b/duty_teller/api/telegram_auth.py index 25c66f1..9b3f6b2 100644 --- a/duty_teller/api/telegram_auth.py +++ b/duty_teller/api/telegram_auth.py @@ -16,7 +16,7 @@ def validate_init_data( max_age_seconds: int | None = None, ) -> str | None: """Validate initData and return username; see validate_init_data_with_reason for failure reason.""" - username, _, _ = validate_init_data_with_reason( + _, username, _, _ = validate_init_data_with_reason( init_data, bot_token, max_age_seconds ) return username @@ -34,14 +34,16 @@ def validate_init_data_with_reason( init_data: str, bot_token: str, max_age_seconds: int | None = None, -) -> tuple[str | None, str, str]: +) -> tuple[int | None, str | None, str, str]: """ - Validate initData signature and return (username, reason, lang) or (None, reason, lang). - reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user", "user_invalid", "no_username". + Validate initData signature and return (telegram_user_id, username, reason, lang). + reason is one of: "ok", "empty", "no_hash", "hash_mismatch", "auth_date_expired", + "no_user", "user_invalid", "no_user_id", "no_username" (legacy; no_user_id used when user.id missing). lang is from user.language_code normalized to 'ru' or 'en'; 'en' when no user. + On success: (user.id, user.get('username') or None, "ok", lang) — username may be None. """ if not init_data or not bot_token: - return (None, "empty", "en") + return (None, None, "empty", "en") init_data = init_data.strip() params = {} for part in init_data.split("&"): @@ -53,7 +55,7 @@ def validate_init_data_with_reason( params[key] = value hash_val = params.pop("hash", None) if not hash_val: - return (None, "no_hash", "en") + return (None, None, "no_hash", "en") data_pairs = sorted(params.items()) # Data-check string: key=value with URL-decoded values (per Telegram example) data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs) @@ -69,28 +71,37 @@ def validate_init_data_with_reason( digestmod=hashlib.sha256, ).hexdigest() if not hmac.compare_digest(computed.lower(), hash_val.lower()): - return (None, "hash_mismatch", "en") + return (None, None, "hash_mismatch", "en") if max_age_seconds is not None and max_age_seconds > 0: auth_date_raw = params.get("auth_date") if not auth_date_raw: - return (None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", "en") try: auth_date = int(float(auth_date_raw)) except (ValueError, TypeError): - return (None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", "en") if time.time() - auth_date > max_age_seconds: - return (None, "auth_date_expired", "en") + return (None, None, "auth_date_expired", "en") user_raw = params.get("user") if not user_raw: - return (None, "no_user", "en") + return (None, None, "no_user", "en") try: user = json.loads(unquote(user_raw)) except (json.JSONDecodeError, TypeError): - return (None, "user_invalid", "en") + return (None, None, "user_invalid", "en") if not isinstance(user, dict): - return (None, "user_invalid", "en") + return (None, None, "user_invalid", "en") lang = _normalize_lang(user.get("language_code")) + raw_id = user.get("id") + if raw_id is None: + return (None, None, "no_user_id", lang) + try: + telegram_user_id = int(raw_id) + except (TypeError, ValueError): + return (None, None, "no_user_id", lang) username = user.get("username") - if not username or not isinstance(username, str): - return (None, "no_username", lang) - return (username.strip().lstrip("@").lower(), "ok", lang) + if username and isinstance(username, str): + username = username.strip().lstrip("@").lower() + else: + username = None + return (telegram_user_id, username, "ok", lang) diff --git a/duty_teller/config.py b/duty_teller/config.py index 0b1d17f..38d9672 100644 --- a/duty_teller/config.py +++ b/duty_teller/config.py @@ -1,5 +1,6 @@ """Load configuration from environment. BOT_TOKEN is not validated on import; check in main/entry point.""" +import re import os from dataclasses import dataclass from pathlib import Path @@ -11,6 +12,16 @@ load_dotenv() # Project root (parent of duty_teller package). Used for webapp path, etc. PROJECT_ROOT = Path(__file__).resolve().parent.parent +# Only digits for phone comparison (same when saving and when checking allowlist). +_PHONE_DIGITS_RE = re.compile(r"\D") + + +def normalize_phone(phone: str | None) -> str: + """Return phone as digits only (spaces, +, parentheses, dashes removed). Empty string if None/empty.""" + if not phone or not isinstance(phone, str): + return "" + return _PHONE_DIGITS_RE.sub("", phone.strip()) + def _normalize_default_language(value: str) -> str: """Normalize DEFAULT_LANGUAGE from env to 'ru' or 'en'.""" @@ -20,6 +31,16 @@ def _normalize_default_language(value: str) -> str: return "ru" if v.startswith("ru") else "en" +def _parse_phone_list(raw: str) -> set[str]: + """Parse comma-separated phones into set of normalized (digits-only) strings.""" + result = set() + for s in (raw or "").strip().split(","): + normalized = normalize_phone(s) + if normalized: + result.add(normalized) + return result + + @dataclass(frozen=True) class Settings: """Optional injectable settings built from env. Tests can override or build from env.""" @@ -30,6 +51,8 @@ class Settings: http_port: int allowed_usernames: set[str] admin_usernames: set[str] + allowed_phones: set[str] + admin_phones: set[str] mini_app_skip_auth: bool init_data_max_age_seconds: int cors_origins: list[str] @@ -49,6 +72,8 @@ class Settings: admin = { s.strip().lstrip("@").lower() for s in raw_admin.split(",") if s.strip() } + allowed_phones = _parse_phone_list(os.getenv("ALLOWED_PHONES", "")) + admin_phones = _parse_phone_list(os.getenv("ADMIN_PHONES", "")) raw_cors = os.getenv("CORS_ORIGINS", "").strip() cors = ( [_o.strip() for _o in raw_cors.split(",") if _o.strip()] @@ -62,6 +87,8 @@ class Settings: http_port=int(os.getenv("HTTP_PORT", "8080")), allowed_usernames=allowed, admin_usernames=admin, + allowed_phones=allowed_phones, + admin_phones=admin_phones, mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes"), init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")), @@ -93,6 +120,9 @@ ADMIN_USERNAMES = { s.strip().lstrip("@").lower() for s in _raw_admin.split(",") if s.strip() } +ALLOWED_PHONES = _parse_phone_list(os.getenv("ALLOWED_PHONES", "")) +ADMIN_PHONES = _parse_phone_list(os.getenv("ADMIN_PHONES", "")) + MINI_APP_SKIP_AUTH = os.getenv("MINI_APP_SKIP_AUTH", "").strip() in ("1", "true", "yes") INIT_DATA_MAX_AGE_SECONDS = int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")) @@ -123,6 +153,20 @@ def can_access_miniapp(username: str) -> bool: return u in ALLOWED_USERNAMES or u in ADMIN_USERNAMES +def can_access_miniapp_by_phone(phone: str | None) -> bool: + """True if normalized phone is in ALLOWED_PHONES or ADMIN_PHONES.""" + normalized = normalize_phone(phone) + if not normalized: + return False + return normalized in ALLOWED_PHONES or normalized in ADMIN_PHONES + + +def is_admin_by_phone(phone: str | None) -> bool: + """True if normalized phone is in ADMIN_PHONES.""" + normalized = normalize_phone(phone) + return bool(normalized and normalized in ADMIN_PHONES) + + def require_bot_token() -> None: """Raise SystemExit with a clear message if BOT_TOKEN is not set. Call from entry point.""" if not BOT_TOKEN: diff --git a/duty_teller/db/repository.py b/duty_teller/db/repository.py index 221fc9b..9d5ca04 100644 --- a/duty_teller/db/repository.py +++ b/duty_teller/db/repository.py @@ -7,6 +7,11 @@ from sqlalchemy.orm import Session from duty_teller.db.models import User, Duty, GroupDutyPin +def get_user_by_telegram_id(session: Session, telegram_user_id: int) -> User | None: + """Find user by telegram_user_id. Returns None if not found (no creation).""" + return session.query(User).filter(User.telegram_user_id == telegram_user_id).first() + + def get_or_create_user( session: Session, telegram_user_id: int, @@ -15,7 +20,7 @@ def get_or_create_user( first_name: str | None = None, last_name: str | None = None, ) -> User: - user = session.query(User).filter(User.telegram_user_id == telegram_user_id).first() + user = get_user_by_telegram_id(session, telegram_user_id) if user: user.full_name = full_name user.username = username diff --git a/tests/test_app.py b/tests/test_app.py index 2296ffe..303d0e7 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -55,8 +55,9 @@ def test_duties_200_when_skip_auth(mock_fetch, client): @patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) def test_duties_403_when_init_data_invalid(mock_validate, client): - mock_validate.return_value = (None, "hash_mismatch", "en") + mock_validate.return_value = (None, None, "hash_mismatch", "en") r = client.get( "/api/duties", params={"from": "2025-01-01", "to": "2025-01-31"}, @@ -72,11 +73,16 @@ def test_duties_403_when_init_data_invalid(mock_validate, client): ) +@patch("duty_teller.api.dependencies.get_user_by_telegram_id") @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") -def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, client): - mock_validate.return_value = ("someuser", "ok", "en") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) +def test_duties_403_when_username_not_allowed( + mock_can_access, mock_validate, mock_get_user, client +): + mock_validate.return_value = (123, "someuser", "ok", "en") mock_can_access.return_value = False + mock_get_user.return_value = None # no user in DB or no phone path with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: r = client.get( "/api/duties", @@ -90,10 +96,87 @@ def test_duties_403_when_username_not_allowed(mock_can_access, mock_validate, cl mock_fetch.assert_not_called() +@patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone") +@patch("duty_teller.api.dependencies.get_user_by_telegram_id") +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.can_access_miniapp") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) +def test_duties_403_when_no_username_and_phone_not_in_allowlist( + mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client +): + """No username in initData and user's phone not in ALLOWED_PHONES -> 403.""" + from types import SimpleNamespace + + mock_validate.return_value = (456, None, "ok", "en") + mock_can_access.return_value = False + mock_get_user.return_value = SimpleNamespace(phone="+79001111111", full_name="User") + mock_can_access_phone.return_value = False + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: + r = client.get( + "/api/duties", + params={"from": "2025-01-01", "to": "2025-01-31"}, + headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, + ) + assert r.status_code == 403 + mock_fetch.assert_not_called() + + +@patch("duty_teller.api.dependencies.config.can_access_miniapp_by_phone") +@patch("duty_teller.api.dependencies.get_user_by_telegram_id") +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.can_access_miniapp") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False) +def test_duties_200_when_no_username_but_phone_in_allowlist( + mock_can_access, mock_validate, mock_get_user, mock_can_access_phone, client +): + """No username in initData but user's phone in ALLOWED_PHONES -> 200.""" + from types import SimpleNamespace + + mock_validate.return_value = (789, None, "ok", "en") + mock_can_access.return_value = False + mock_get_user.return_value = SimpleNamespace( + phone="+7 900 123-45-67", full_name="Иван" + ) + mock_can_access_phone.return_value = True + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: + mock_fetch.return_value = [] + r = client.get( + "/api/duties", + params={"from": "2025-01-01", "to": "2025-01-31"}, + headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, + ) + assert r.status_code == 200 + assert r.json() == [] + mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") + + +@patch("duty_teller.api.dependencies.validate_init_data_with_reason") +@patch("duty_teller.api.dependencies.config.can_access_miniapp") +@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True) +def test_duties_200_with_valid_init_data_and_skip_auth_not_in_allowlist( + mock_can_access, mock_validate, client +): + """With MINI_APP_SKIP_AUTH=True, valid initData grants access without allowlist check.""" + mock_validate.return_value = ("outsider_user", "ok", "en") + mock_can_access.return_value = False + with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: + mock_fetch.return_value = [] + r = client.get( + "/api/duties", + params={"from": "2025-01-01", "to": "2025-01-31"}, + headers={"X-Telegram-Init-Data": "user=xxx&hash=yyy"}, + ) + assert r.status_code == 200 + assert r.json() == [] + mock_fetch.assert_called_once_with(ANY, "2025-01-01", "2025-01-31") + mock_can_access.assert_not_called() + mock_validate.assert_not_called() + + @patch("duty_teller.api.dependencies.validate_init_data_with_reason") @patch("duty_teller.api.dependencies.config.can_access_miniapp") def test_duties_200_with_allowed_user(mock_can_access, mock_validate, client): - mock_validate.return_value = ("alloweduser", "ok", "en") + mock_validate.return_value = (1, "alloweduser", "ok", "en") mock_can_access.return_value = True with patch("duty_teller.api.app.fetch_duties_response") as mock_fetch: mock_fetch.return_value = [ diff --git a/tests/test_config.py b/tests/test_config.py index da03ad4..9109656 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -34,3 +34,36 @@ def test_can_access_miniapp_denied(monkeypatch): monkeypatch.setattr(config, "ADMIN_USERNAMES", set()) assert config.can_access_miniapp("other") is False assert config.can_access_miniapp("") is False + + +def test_normalize_phone(): + """Phone is normalized to digits only.""" + assert config.normalize_phone("+7 900 123-45-67") == "79001234567" + assert config.normalize_phone("8 (900) 123-45-67") == "89001234567" + assert config.normalize_phone("79001234567") == "79001234567" + assert config.normalize_phone("") == "" + assert config.normalize_phone(None) == "" + + +def test_can_access_miniapp_by_phone(monkeypatch): + monkeypatch.setattr(config, "ALLOWED_PHONES", {"79001234567", "79007654321"}) + monkeypatch.setattr(config, "ADMIN_PHONES", set()) + assert config.can_access_miniapp_by_phone("+7 900 123-45-67") is True + assert config.can_access_miniapp_by_phone("79007654321") is True + assert config.can_access_miniapp_by_phone("79001111111") is False + assert config.can_access_miniapp_by_phone(None) is False + assert config.can_access_miniapp_by_phone("") is False + + +def test_can_access_miniapp_by_phone_admin(monkeypatch): + monkeypatch.setattr(config, "ALLOWED_PHONES", set()) + monkeypatch.setattr(config, "ADMIN_PHONES", {"79001111111"}) + assert config.can_access_miniapp_by_phone("+7 900 111-11-11") is True + assert config.can_access_miniapp_by_phone("79002222222") is False + + +def test_is_admin_by_phone(monkeypatch): + monkeypatch.setattr(config, "ADMIN_PHONES", {"79001111111"}) + assert config.is_admin_by_phone("+7 900 111-11-11") is True + assert config.is_admin_by_phone("79001234567") is False + assert config.is_admin_by_phone(None) is False diff --git a/tests/test_telegram_auth.py b/tests/test_telegram_auth.py index dcfcc79..0820b3d 100644 --- a/tests/test_telegram_auth.py +++ b/tests/test_telegram_auth.py @@ -1,6 +1,9 @@ -"""Tests for duty_teller.api.telegram_auth.validate_init_data.""" +"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason.""" -from duty_teller.api.telegram_auth import validate_init_data +from duty_teller.api.telegram_auth import ( + validate_init_data, + validate_init_data_with_reason, +) from tests.helpers import make_init_data @@ -40,13 +43,42 @@ def test_missing_user_returns_none(): assert validate_init_data(init_data, bot_token) is None -def test_user_without_username_returns_none(): +def test_user_without_username_returns_none_from_validate_init_data(): + """validate_init_data returns None when user has no username (backward compat).""" bot_token = "123:ABC" user = {"id": 123, "first_name": "Test"} # no username init_data = make_init_data(user, bot_token) assert validate_init_data(init_data, bot_token) is None +def test_user_without_username_but_with_id_succeeds_with_reason(): + """With validate_init_data_with_reason, valid user.id is enough; username may be None.""" + bot_token = "123:ABC" + user = {"id": 456, "first_name": "Test", "language_code": "ru"} + init_data = make_init_data(user, bot_token) + telegram_user_id, username, reason, lang = validate_init_data_with_reason( + init_data, bot_token + ) + assert telegram_user_id == 456 + assert username is None + assert reason == "ok" + assert lang == "ru" + + +def test_user_without_id_returns_no_user_id(): + """When user object exists but has no 'id', return no_user_id.""" + bot_token = "123:ABC" + user = {"first_name": "Test"} # no id + init_data = make_init_data(user, bot_token) + telegram_user_id, username, reason, lang = validate_init_data_with_reason( + init_data, bot_token + ) + assert telegram_user_id is None + assert username is None + assert reason == "no_user_id" + assert lang == "en" + + def test_empty_init_data_returns_none(): assert validate_init_data("", "token") is None assert validate_init_data(" ", "token") is None diff --git a/webapp/app.js b/webapp/app.js index 82e22a8..3ecd70f 100644 --- a/webapp/app.js +++ b/webapp/app.js @@ -295,13 +295,13 @@ let html = "" + d.getDate() + "
"; if (showMarkers) { if (dutyList.length) { - html += "Д"; + html += ""; } if (unavailableList.length) { - html += "Н"; + html += ""; } if (vacationList.length) { - html += "О"; + html += ""; } if (hasEvent) { html += ""; @@ -372,8 +372,9 @@ btn.addEventListener("click", function (e) { e.stopPropagation(); var summary = btn.getAttribute("data-summary") || ""; - if (hintEl.hidden || hintEl.textContent !== summary) { - hintEl.textContent = summary; + var content = "События:\n" + summary; + if (hintEl.hidden || hintEl.textContent !== content) { + hintEl.textContent = content; var rect = btn.getBoundingClientRect(); positionHint(hintEl, rect); hintEl.dataset.active = "1"; @@ -400,6 +401,20 @@ } } + var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" }; + + function getDutyMarkerHintContent(marker) { + var type = marker.getAttribute("data-event-type") || "duty"; + var label = EVENT_TYPE_LABELS[type] || type; + var names = (marker.getAttribute("data-names") || "").replace(/\n/g, ", "); + return names ? label + ": " + names : label; + } + + function clearActiveDutyMarker() { + calendarEl.querySelectorAll(".duty-marker.calendar-marker-active, .unavailable-marker.calendar-marker-active, .vacation-marker.calendar-marker-active") + .forEach(function (m) { m.classList.remove("calendar-marker-active"); }); + } + function bindDutyMarkerTooltips() { var hintEl = document.getElementById("dutyMarkerHint"); if (!hintEl) { @@ -418,23 +433,54 @@ clearTimeout(hideTimeout); hideTimeout = null; } - var names = marker.getAttribute("data-names") || ""; - hintEl.textContent = names; + hintEl.textContent = getDutyMarkerHintContent(marker); var rect = marker.getBoundingClientRect(); positionHint(hintEl, rect); hintEl.hidden = false; }); marker.addEventListener("mouseleave", function () { + if (hintEl.dataset.active) { return; } hideTimeout = setTimeout(function () { hintEl.hidden = true; hideTimeout = null; }, 150); }); + marker.addEventListener("click", function (e) { + e.stopPropagation(); + if (marker.classList.contains("calendar-marker-active")) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + marker.classList.remove("calendar-marker-active"); + return; + } + clearActiveDutyMarker(); + hintEl.textContent = getDutyMarkerHintContent(marker); + var rect = marker.getBoundingClientRect(); + positionHint(hintEl, rect); + hintEl.hidden = false; + hintEl.dataset.active = "1"; + marker.classList.add("calendar-marker-active"); + }); }); + if (!document._dutyMarkerHintBound) { + document._dutyMarkerHintBound = true; + document.addEventListener("click", function () { + if (hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + } + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && hintEl.dataset.active) { + hintEl.hidden = true; + hintEl.removeAttribute("data-active"); + clearActiveDutyMarker(); + } + }); + } } - var EVENT_TYPE_LABELS = { duty: "Дежурство", unavailable: "Недоступен", vacation: "Отпуск" }; - /** Format UTC date from ISO string as DD.MM for display. */ /** Format date as DD.MM in user's local timezone (for duty card labels). */ function formatDateKey(isoDateStr) { @@ -540,7 +586,17 @@ dutyListEl.innerHTML = fullHtml; var scrollTarget = dutyListEl.querySelector(".duty-timeline-day--today"); if (scrollTarget) { - scrollTarget.scrollIntoView({ behavior: "auto", block: "start" }); + var calendarSticky = document.getElementById("calendarSticky"); + if (calendarSticky) { + requestAnimationFrame(function () { + var calendarHeight = calendarSticky.offsetHeight; + var todayTop = scrollTarget.getBoundingClientRect().top + window.scrollY; + var scrollTop = Math.max(0, todayTop - calendarHeight); + window.scrollTo({ top: scrollTop, behavior: "auto" }); + }); + } else { + scrollTarget.scrollIntoView({ behavior: "auto", block: "start" }); + } } } diff --git a/webapp/style.css b/webapp/style.css index 9a38652..3851455 100644 --- a/webapp/style.css +++ b/webapp/style.css @@ -245,7 +245,7 @@ body { transform: none; } -/* Единый размер маркеров (11px), чтобы и один, и три в строке выглядели одинаково */ +/* Маркеры: компактный размер 11px, кнопки для доступности и тапа */ .duty-marker, .unavailable-marker, .vacation-marker { @@ -254,10 +254,13 @@ body { justify-content: center; width: 11px; height: 11px; + padding: 0; + border: none; font-size: 0.55rem; font-weight: 700; border-radius: 50%; flex-shrink: 0; + cursor: pointer; } .duty-marker { @@ -275,6 +278,18 @@ body { background: color-mix(in srgb, var(--vacation) 25%, transparent); } +.duty-marker.calendar-marker-active { + box-shadow: 0 0 0 2px var(--duty); +} + +.unavailable-marker.calendar-marker-active { + box-shadow: 0 0 0 2px var(--unavailable); +} + +.vacation-marker.calendar-marker-active { + box-shadow: 0 0 0 2px var(--vacation); +} + .duty-list { font-size: 0.9rem; }