13 Commits

Author SHA1 Message Date
0d28123d0b feat: enhance current duty display with remaining time and improved contact links
All checks were successful
CI / lint-and-test (push) Successful in 35s
Docker Build and Release / build-and-push (push) Successful in 51s
Docker Build and Release / release (push) Successful in 8s
- Added a new message key for displaying remaining time until the end of the shift in both English and Russian.
- Updated the current duty card to show remaining time with a formatted string.
- Enhanced the contact links to support block layout with icons for phone and Telegram, improving visual presentation.
- Implemented a new utility function to calculate remaining time until the end of the shift.
- Added unit tests for the new functionality, ensuring accurate time calculations and proper rendering of contact links.
2026-03-02 19:04:30 +03:00
2e78b3c1e6 style: update calendar CSS for improved layout and visual consistency
All checks were successful
CI / lint-and-test (push) Successful in 33s
- Adjusted the layout of the `.day-indicator` to use a fixed width for better alignment.
- Modified the `.day-indicator-dot` styles to enhance flexibility and visual appearance, including changes to height and border-radius for better presentation.
- Ensured that the first and last dots have distinct border-radius styles when not the only child, improving the overall aesthetics of the calendar display.
- No functional changes were made; the focus was on enhancing the visual presentation of the calendar component.
2026-03-02 18:03:18 +03:00
bdead6eef7 refactor: improve code formatting and readability in configuration and run files
All checks were successful
CI / lint-and-test (push) Successful in 38s
- Simplified the assignment of `bot_username` in `config.py` for better clarity.
- Removed redundant import statement in `run.py` to streamline the code.
- Enhanced formatting in `group_duty_pin.py` and test files for improved readability and consistency.
- No functional changes were made; the focus was on code style and organization.
2026-03-02 17:22:55 +03:00
2fb553567f feat: enhance CI workflow and update webapp styles
Some checks failed
CI / lint-and-test (push) Failing after 45s
- Added Node.js setup and webapp testing steps to the CI workflow for improved integration.
- Updated HTML to link multiple CSS files for better modularity and organization of styles.
- Removed deprecated `style.css` and introduced new CSS files for base styles, calendar, day detail, hints, markers, states, and duty list to enhance maintainability and readability.
- Implemented new styles for improved presentation of duty information and user interactions.
- Added unit tests for new API functions and contact link rendering to ensure functionality and reliability.
2026-03-02 17:20:33 +03:00
e3240d0981 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.
2026-03-02 16:09:08 +03:00
f8aceabab5 feat: add trusted groups functionality for duty information
- Introduced a new `trusted_groups` table to store groups authorized to receive duty information.
- Implemented functions to add, remove, and check trusted groups in the database.
- Enhanced command handlers to manage trusted groups, including `/trust_group` and `/untrust_group` commands for admin users.
- Updated internationalization messages to support new commands and group status notifications.
- Added unit tests for trusted groups repository functions to ensure correct behavior and data integrity.
2026-03-02 13:07:13 +03:00
322b553b80 feat: enhance group duty pin functionality to delete old messages
- Updated the `_refresh_pin_for_chat` function to delete the old pinned message after sending a new one, ensuring a cleaner chat experience.
- Modified related unit tests to verify the new deletion behavior, including handling exceptions when the old message cannot be deleted.
- Improved documentation in test cases to reflect the updated functionality and error handling.
2026-03-02 12:51:28 +03:00
a4d8d085c6 feat: update language support and enhance API functionality
- Changed the default language in `index.html` from Russian to English, updating the title and button aria-labels for improved accessibility.
- Refactored the `buildFetchOptions` function in `api.js` to include an optional external abort signal, enhancing request management.
- Updated `fetchDuties` and `fetchCalendarEvents` to support request cancellation using the new abort signal, improving error handling.
- Added unit tests for the API functions to ensure proper functionality, including handling of 403 errors and request cancellations.
- Enhanced CSS styles for duty markers to improve visual consistency.
- Removed unused code and improved the overall structure of the JavaScript files for better maintainability.
2026-03-02 12:40:49 +03:00
b906bfa777 refactor: improve code formatting and readability in group duty pin command and tests
All checks were successful
CI / lint-and-test (push) Successful in 25s
Docker Build and Release / build-and-push (push) Successful in 54s
Docker Build and Release / release (push) Successful in 8s
- Enhanced the `pin_duty_cmd` function by improving code formatting for better readability, ensuring consistent style across the codebase.
- Updated unit tests for `pin_duty_cmd` to follow the same formatting improvements, enhancing clarity and maintainability.
- No functional changes were made; the focus was solely on code style and organization.
2026-02-25 14:58:03 +03:00
8a80af32d8 feat: enhance group duty pin command functionality
All checks were successful
CI / lint-and-test (push) Successful in 25s
Docker Build and Release / build-and-push (push) Successful in 56s
Docker Build and Release / release (push) Successful in 9s
- Updated the `pin_duty_cmd` to handle cases where no message ID is found by sending a new duty message, pinning it, saving the pin, and scheduling the next update.
- Improved error handling for message sending and pinning operations, providing appropriate replies based on success or failure.
- Enhanced unit tests to cover the new behavior, ensuring proper functionality and error handling in various scenarios.
2026-02-25 14:43:19 +03:00
3c3a2c507c chore: remove egg-info metadata files
All checks were successful
CI / lint-and-test (push) Successful in 25s
- Deleted egg-info metadata files including dependency_links.txt, entry_points.txt, PKG-INFO, requires.txt, SOURCES.txt, and top_level.txt to clean up the project structure.
- Removed the .coverage file to eliminate unnecessary coverage data tracking.
2026-02-25 13:49:57 +03:00
71d56d2491 chore: update .gitignore to exclude .cursorrules directory
All checks were successful
CI / lint-and-test (push) Successful in 24s
- Added .cursorrules/ to the .gitignore file to prevent tracking of cursor rule files in version control.
2026-02-25 13:49:04 +03:00
0e8d1453e2 feat: implement caching for duty-related data and enhance performance
All checks were successful
CI / lint-and-test (push) Successful in 24s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 8s
- Added a TTLCache class for in-memory caching of duty-related data, improving performance by reducing database queries.
- Integrated caching into the group duty pin functionality, allowing for efficient retrieval of message text and next shift end times.
- Introduced new methods to invalidate caches when relevant data changes, ensuring data consistency.
- Created a new Alembic migration to add indexes on the duties table for improved query performance.
- Updated tests to cover the new caching behavior and ensure proper functionality.
2026-02-25 13:25:34 +03:00
70 changed files with 5686 additions and 1603 deletions

BIN
.coverage

Binary file not shown.

View File

@@ -39,3 +39,14 @@ jobs:
- name: Security check with Bandit
run: |
bandit -r duty_teller -ll
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: "20"
- name: Webapp tests
run: |
cd webapp
npm ci
npm run test

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ venv/
data/
*.db
.cursor/
.cursorrules/
# Test and coverage artifacts
.coverage
htmlcov/

View File

@@ -0,0 +1,44 @@
"""Add indexes on duties table for performance.
Revision ID: 008
Revises: 007
Create Date: 2025-02-25
Indexes for get_current_duty, get_next_shift_end, get_duties, get_duties_for_user.
"""
from typing import Sequence, Union
from alembic import op
revision: str = "008"
down_revision: Union[str, None] = "007"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_duties_event_type_start_at",
"duties",
["event_type", "start_at"],
unique=False,
)
op.create_index(
"ix_duties_event_type_end_at",
"duties",
["event_type", "end_at"],
unique=False,
)
op.create_index(
"ix_duties_user_id_start_at",
"duties",
["user_id", "start_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index("ix_duties_user_id_start_at", table_name="duties")
op.drop_index("ix_duties_event_type_end_at", table_name="duties")
op.drop_index("ix_duties_event_type_start_at", table_name="duties")

View File

@@ -0,0 +1,32 @@
"""Add trusted_groups table.
Revision ID: 009
Revises: 008
Create Date: 2025-03-02
Table for groups authorized to receive duty information.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "009"
down_revision: Union[str, None] = "008"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"trusted_groups",
sa.Column("chat_id", sa.BigInteger(), nullable=False),
sa.Column("added_by_user_id", sa.BigInteger(), nullable=True),
sa.Column("added_at", sa.Text(), nullable=False),
sa.PrimaryKeyConstraint("chat_id"),
)
def downgrade() -> None:
op.drop_table("trusted_groups")

View File

@@ -1,181 +0,0 @@
Metadata-Version: 2.4
Name: duty-teller
Version: 0.1.0
Summary: Telegram bot for team duty shift calendar and group reminder
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: python-telegram-bot[job-queue]<23.0,>=22.0
Requires-Dist: python-dotenv<2.0,>=1.0
Requires-Dist: fastapi<1.0,>=0.115
Requires-Dist: uvicorn[standard]<1.0,>=0.32
Requires-Dist: sqlalchemy<3.0,>=2.0
Requires-Dist: alembic<2.0,>=1.14
Requires-Dist: pydantic<3.0,>=2.0
Requires-Dist: icalendar<6.0,>=5.0
Provides-Extra: dev
Requires-Dist: pytest<9.0,>=8.0; extra == "dev"
Requires-Dist: pytest-asyncio<2.0,>=1.0; extra == "dev"
Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
Requires-Dist: httpx<1.0,>=0.27; extra == "dev"
Provides-Extra: docs
Requires-Dist: mkdocs<2,>=1.5; extra == "docs"
Requires-Dist: mkdocstrings[python]<1,>=0.24; extra == "docs"
Requires-Dist: mkdocs-material<10,>=9.0; extra == "docs"
# Duty Teller (Telegram Bot)
A minimal Telegram bot boilerplate using [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 with the `Application` API. The bot and web UI support **Russian and English** (language from Telegram or `DEFAULT_LANGUAGE`).
**History of changes:** [CHANGELOG.md](CHANGELOG.md).
## Get a bot token
1. Open Telegram and search for [@BotFather](https://t.me/BotFather).
2. Send `/newbot` and follow the prompts to create a bot.
3. Copy the token BotFather gives you.
## Setup
1. **Clone and enter the project**
```bash
cd duty-teller
```
2. **Create a virtual environment (recommended)**
```bash
python -m venv venv
source venv/bin/activate # Linux/macOS
# or: venv\Scripts\activate # Windows
```
3. **Install dependencies**
```bash
pip install -r requirements.txt
```
4. **Configure the bot**
```bash
cp .env.example .env
```
Edit `.env` and set `BOT_TOKEN` to the token from BotFather.
5. **Miniapp access (calendar)**
Access is controlled by **roles in the DB** (assigned by an admin with `/set_role @username user|admin`). Set `ADMIN_USERNAMES` (and optionally `ADMIN_PHONES`) so that at least one admin can use the bot and assign roles; these also act as a fallback for admin when a user has no role in the DB. See [docs/configuration.md](docs/configuration.md).
**Mini App URL:** When configuring the bot's menu button or Web App URL (e.g. in @BotFather or via `setChatMenuButton`), use the URL **with a trailing slash**, e.g. `https://your-domain.com/app/`. A redirect from `/app` to `/app/` can cause the browser to drop the fragment that Telegram sends, which breaks authorization.
**How to open:** Users must open the calendar **via the bot's menu button** (⋮ → "Calendar" or the configured label) or a **Web App inline button**. If they use "Open in browser" or a direct link, Telegram may not send user data (`tgWebAppData`), and access will be denied.
**BOT_TOKEN:** The server that serves `/api/duties` (e.g. your production host) must have in `.env` the **same** bot token as the bot from which users open the Mini App. If the token differs (e.g. test vs production bot), validation returns "hash_mismatch" and access is denied.
6. **Other options**
Full list of environment variables (types, defaults, examples): **[docs/configuration.md](docs/configuration.md)**.
## Run
```bash
python main.py
```
Or after `pip install -e .`:
```bash
duty-teller
```
The bot runs in polling mode. Send `/start` or `/help` to your bot in Telegram to test.
## Bot commands
- **`/start`** — Greeting and user registration in the database.
- **`/help`** — Help on available commands.
- **`/set_phone [number]`** — Set or clear phone number (private chat only); used for access via `ALLOWED_PHONES` / `ADMIN_PHONES`.
- **`/import_duty_schedule`** — Import duty schedule (admin only); see **Duty schedule import** below for the two-step flow.
- **`/set_role @username user|admin`** — Set a users role (admin only). Alternatively, reply to a message and send `/set_role user|admin`.
- **`/pin_duty`** — Pin the current duty message in a group (reply to the bots duty message); time/timezone for the pinned message come from `DUTY_DISPLAY_TZ`.
## Run with Docker
Ensure `.env` exists (e.g. `cp .env.example .env`) and contains `BOT_TOKEN`.
- **Dev** (volume mount; code changes apply without rebuild):
```bash
docker compose -f docker-compose.dev.yml up --build
```
Stop with `Ctrl+C` or `docker compose -f docker-compose.dev.yml down`.
- **Prod** (no volume; runs the built image; restarts on failure):
```bash
docker compose -f docker-compose.prod.yml up -d --build
```
For production deployments you may use Docker secrets or your orchestrators env instead of a `.env` file.
The image is built from `Dockerfile`; on start, `entrypoint.sh` runs Alembic migrations then starts the app as user `botuser`.
**Production behind a reverse proxy:** When the app is behind nginx/Caddy etc., `request.client.host` is usually the proxy (e.g. 127.0.0.1). The "private IP" bypass (allowing requests without initData from localhost) then applies to the proxy, not the real client. Either ensure the Mini App always sends initData, or forward the real client IP (e.g. `X-Forwarded-For`) and use it for that check. See `api/app.py` `_is_private_client` for details.
## API
The HTTP server is FastAPI; the miniapp is served at `/app`.
**Interactive API documentation (Swagger UI)** is available at **`/docs`**, e.g. `http://localhost:8080/docs` when running locally.
- **`GET /api/duties`** — List of duties (date params; auth via Telegram initData or, in dev, `MINI_APP_SKIP_AUTH` / private IP).
- **`GET /api/calendar-events`** — Calendar events (including external ICS when `EXTERNAL_CALENDAR_ICS_URL` is set).
- **`GET /api/calendar/ical/{token}.ics`** — Personal ICS calendar (by secret token; no Telegram auth).
For production, initData validation is required; see the reverse-proxy paragraph above for proxy/headers.
## Project layout
High-level architecture (components, data flow, package relationships) is described in [docs/architecture.md](docs/architecture.md).
- `main.py` Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
- `duty_teller/` Main package (install with `pip install -e .`). Contains:
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
- `db/` SQLAlchemy models, session (`session_scope`), repository, schemas.
- `handlers/` Telegram command and chat handlers; register via `register_handlers(app)`.
- `i18n/` Translations and language detection (ru/en); used by handlers and API.
- `services/` Business logic (group duty pin, import); accept session from caller.
- `utils/` Shared date, user, and handover helpers.
- `importers/` Duty-schedule JSON parser.
- `alembic/` Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
- `webapp/` Miniapp UI (calendar, duty list); served at `/app`.
- `tests/` Tests; `helpers.py` provides `make_init_data` for auth tests.
- `pyproject.toml` Installable package (`pip install -e .`).
**Documentation:** The `docs/` folder contains configuration reference, architecture, import format, and runbook. API reference is generated from the code. Build: `mkdocs build` (requires `pip install -e ".[docs]"`). Preview: `mkdocs serve`.
To add commands, define async handlers in `duty_teller/handlers/commands.py` (or a new module) and register them in `duty_teller/handlers/__init__.py`.
## Duty schedule import (duty-schedule)
The **`/import_duty_schedule`** command is available only to users in `ADMIN_USERNAMES` or `ADMIN_PHONES`. Import is done in two steps:
1. **Handover time** — The bot asks for the shift handover time and optional timezone (e.g. `09:00 Europe/Moscow` or `06:00 UTC`). This is converted to UTC and used as the boundary between duty periods when creating records.
2. **JSON file** — Send a file in duty-schedule format.
Format: at the root of the JSON — a **meta** object with `start_date` (YYYY-MM-DD) and a **schedule** array of objects with `name` (full name) and `duty` (string with separator `;`; characters **в/В/б/Б** = duty, **Н** = unavailable, **О** = vacation). The number of days is given by the length of the `duty` string. On re-import, duties in the same date range for each user are replaced by the new data.
**Full format description and example JSON:** [docs/import-format.md](docs/import-format.md).
## Tests
Run from the repository root (no `src/` directory; package is `duty_teller` at the root). Use `PYTHONPATH=.` if needed:
```bash
pip install -r requirements-dev.txt
pytest
```
Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config` (is_admin, can_access_miniapp), and the API (date validation, 403/200 with mocked auth, plus an E2E auth test without auth mocks).
**CI (Gitea Actions):** Lint (ruff), tests (pytest), security (bandit). If the workflow uses `PYTHONPATH: src` or `bandit -r src`, update it to match the repo layout (no `src/`).
## Contributing
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
## Logs and rotation
To meet the 7-day log retention policy, configure log rotation at deploy time: e.g. [logrotate](https://manpages.ubuntu.com/logrotate), systemd logging settings, or Docker (size/time retention limits). Keep application logs for no more than 7 days.

View File

@@ -1,63 +0,0 @@
README.md
pyproject.toml
duty_teller/__init__.py
duty_teller/config.py
duty_teller/run.py
duty_teller.egg-info/PKG-INFO
duty_teller.egg-info/SOURCES.txt
duty_teller.egg-info/dependency_links.txt
duty_teller.egg-info/entry_points.txt
duty_teller.egg-info/requires.txt
duty_teller.egg-info/top_level.txt
duty_teller/api/__init__.py
duty_teller/api/app.py
duty_teller/api/calendar_ics.py
duty_teller/api/dependencies.py
duty_teller/api/personal_calendar_ics.py
duty_teller/api/telegram_auth.py
duty_teller/db/__init__.py
duty_teller/db/models.py
duty_teller/db/repository.py
duty_teller/db/schemas.py
duty_teller/db/session.py
duty_teller/handlers/__init__.py
duty_teller/handlers/commands.py
duty_teller/handlers/common.py
duty_teller/handlers/errors.py
duty_teller/handlers/group_duty_pin.py
duty_teller/handlers/import_duty_schedule.py
duty_teller/i18n/__init__.py
duty_teller/i18n/core.py
duty_teller/i18n/lang.py
duty_teller/i18n/messages.py
duty_teller/importers/__init__.py
duty_teller/importers/duty_schedule.py
duty_teller/services/__init__.py
duty_teller/services/group_duty_pin_service.py
duty_teller/services/import_service.py
duty_teller/utils/__init__.py
duty_teller/utils/dates.py
duty_teller/utils/handover.py
duty_teller/utils/user.py
tests/test_api_dependencies.py
tests/test_app.py
tests/test_calendar_ics.py
tests/test_calendar_token_repository.py
tests/test_config.py
tests/test_db_session.py
tests/test_duty_schedule_parser.py
tests/test_group_duty_pin_service.py
tests/test_handlers_commands.py
tests/test_handlers_errors.py
tests/test_handlers_group_duty_pin.py
tests/test_handlers_init.py
tests/test_i18n.py
tests/test_import_duty_schedule_integration.py
tests/test_import_service.py
tests/test_package_init.py
tests/test_personal_calendar_ics.py
tests/test_repository_duty_range.py
tests/test_repository_roles.py
tests/test_run.py
tests/test_telegram_auth.py
tests/test_utils.py

View File

@@ -1 +0,0 @@

View File

@@ -1,2 +0,0 @@
[console_scripts]
duty-teller = duty_teller.run:main

View File

@@ -1,19 +0,0 @@
python-telegram-bot[job-queue]<23.0,>=22.0
python-dotenv<2.0,>=1.0
fastapi<1.0,>=0.115
uvicorn[standard]<1.0,>=0.32
sqlalchemy<3.0,>=2.0
alembic<2.0,>=1.14
pydantic<3.0,>=2.0
icalendar<6.0,>=5.0
[dev]
pytest<9.0,>=8.0
pytest-asyncio<2.0,>=1.0
pytest-cov<7.0,>=6.0
httpx<1.0,>=0.27
[docs]
mkdocs<2,>=1.5
mkdocstrings[python]<1,>=0.24
mkdocs-material<10,>=9.0

View File

@@ -1 +0,0 @@
duty_teller

View File

@@ -3,7 +3,10 @@
import logging
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
@@ -18,6 +21,7 @@ from duty_teller.api.dependencies import (
require_miniapp_username,
)
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
from duty_teller.cache import ics_calendar_cache
from duty_teller.db.repository import (
get_duties,
get_duties_for_user,
@@ -54,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],
@@ -116,14 +136,20 @@ def get_team_calendar_ical(
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
]
ics_bytes = build_team_ics(duties_duty_only)
cache_key = ("team_ics",)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [
(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)
return Response(
content=ics_bytes,
media_type="text/calendar; charset=utf-8",
@@ -151,13 +177,17 @@ def get_personal_calendar_ical(
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
duties_with_name = get_duties_for_user(
session, user.id, from_date=from_date, to_date=to_date, event_types=["duty"]
)
ics_bytes = build_personal_ics(duties_with_name)
cache_key = ("personal_ics", user.id)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
today = date.today()
from_date = (today - timedelta(days=365)).strftime("%Y-%m-%d")
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
duties_with_name = get_duties_for_user(
session, user.id, from_date=from_date, to_date=to_date, event_types=["duty"]
)
ics_bytes = build_personal_ics(duties_with_name)
ics_calendar_cache.set(cache_key, ics_bytes)
return Response(
content=ics_bytes,
media_type="text/calendar; charset=utf-8",

View File

@@ -7,12 +7,15 @@ from urllib.error import URLError
from icalendar import Calendar
from duty_teller.cache import TTLCache
from duty_teller.utils.http_client import safe_urlopen
log = logging.getLogger(__name__)
# In-memory cache: url -> (cached_at_timestamp, raw_ics_bytes)
# Raw ICS bytes cache: url -> (cached_at_timestamp, raw_ics_bytes)
_ics_cache: dict[str, tuple[float, bytes]] = {}
# Parsed events cache: url -> list of {date, summary}. TTL 7 days.
_parsed_events_cache = TTLCache(ttl_seconds=7 * 24 * 3600, max_size=100)
CACHE_TTL_SECONDS = 7 * 24 * 3600 # 1 week
FETCH_TIMEOUT_SECONDS = 15
@@ -68,8 +71,8 @@ def _event_date_range(component) -> tuple[date | None, date | None]:
return (start_d, last_d)
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
"""Parse ICS bytes and return list of {date, summary} in [from_date, to_date]. One-time events only."""
def _parse_ics_to_events(raw: bytes) -> list[dict]:
"""Parse ICS bytes and return all events as list of {date, summary}. One-time events only."""
result: list[dict] = []
try:
cal = Calendar.from_ical(raw)
@@ -79,9 +82,6 @@ def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]
log.warning("Failed to parse ICS: %s", e)
return result
from_d = date.fromisoformat(from_date)
to_d = date.fromisoformat(to_date)
for component in cal.walk():
if component.name != "VEVENT":
continue
@@ -95,13 +95,27 @@ def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]
d = start_d
while d <= end_d:
if from_d <= d <= to_d:
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
result.append({"date": d.strftime("%Y-%m-%d"), "summary": summary_str})
d += timedelta(days=1)
return result
def _filter_events_by_range(
events: list[dict], from_date: str, to_date: str
) -> list[dict]:
"""Filter events list to [from_date, to_date] range."""
from_d = date.fromisoformat(from_date)
to_d = date.fromisoformat(to_date)
return [e for e in events if from_d <= date.fromisoformat(e["date"]) <= to_d]
def _get_events_from_ics(raw: bytes, from_date: str, to_date: str) -> list[dict]:
"""Parse ICS bytes and return events in [from_date, to_date]. Wrapper for tests."""
events = _parse_ics_to_events(raw)
return _filter_events_by_range(events, from_date, to_date)
def get_calendar_events(
url: str,
from_date: str,
@@ -135,4 +149,10 @@ def get_calendar_events(
return []
_ics_cache[url] = (now, raw)
return _get_events_from_ics(raw, from_date, to_date)
# Use parsed events cache to avoid repeated Calendar.from_ical() + walk()
cache_key = (url,)
events, found = _parsed_events_cache.get(cache_key)
if not found:
events = _parse_ics_to_events(raw)
_parsed_events_cache.set(cache_key, events)
return _filter_events_by_range(events, from_date, to_date)

View File

@@ -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
]

125
duty_teller/cache.py Normal file
View File

@@ -0,0 +1,125 @@
"""Simple in-memory TTL cache. Thread-safe for get/set."""
import logging
from threading import Lock
from time import time
log = logging.getLogger(__name__)
class TTLCache:
"""Thread-safe TTL cache with optional max size and pattern invalidation."""
def __init__(self, ttl_seconds: float, max_size: int = 1000) -> None:
"""Initialize cache with TTL and optional max size.
Args:
ttl_seconds: Time-to-live in seconds for each entry.
max_size: Maximum number of entries (0 = unlimited). LRU eviction when exceeded.
"""
self._ttl = ttl_seconds
self._max_size = max_size
self._data: dict[tuple, tuple[float, object]] = {} # key -> (cached_at, value)
self._lock = Lock()
self._access_order: list[tuple] = [] # For LRU when max_size > 0
def get(self, key: tuple) -> tuple[object, bool]:
"""Get value by key if present and not expired.
Args:
key: Cache key (must be hashable, typically tuple).
Returns:
(value, found) — found is True if valid cached value exists.
"""
with self._lock:
entry = self._data.get(key)
if entry is None:
return (None, False)
cached_at, value = entry
if time() - cached_at >= self._ttl:
del self._data[key]
if self._max_size > 0 and key in self._access_order:
self._access_order.remove(key)
return (None, False)
if self._max_size > 0 and key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
return (value, True)
def set(self, key: tuple, value: object) -> None:
"""Store value with current timestamp.
Args:
key: Cache key (must be hashable).
value: Value to cache.
"""
with self._lock:
now = time()
if (
self._max_size > 0
and len(self._data) >= self._max_size
and key not in self._data
):
# Evict oldest
while self._access_order and len(self._data) >= self._max_size:
old_key = self._access_order.pop(0)
if old_key in self._data:
del self._data[old_key]
self._data[key] = (now, value)
if self._max_size > 0:
if key in self._access_order:
self._access_order.remove(key)
self._access_order.append(key)
def invalidate(self, key: tuple) -> None:
"""Remove a single key from cache.
Args:
key: Cache key to remove.
"""
with self._lock:
if key in self._data:
del self._data[key]
if self._max_size > 0 and key in self._access_order:
self._access_order.remove(key)
def clear(self) -> None:
"""Remove all entries. Useful for tests."""
with self._lock:
self._data.clear()
self._access_order.clear()
def invalidate_pattern(self, key_prefix: tuple) -> None:
"""Remove all keys that start with the given prefix.
Args:
key_prefix: Prefix tuple (e.g. ("personal",) matches ("personal", 1), ("personal", 2)).
"""
with self._lock:
to_remove = [k for k in self._data if self._key_starts_with(k, key_prefix)]
for k in to_remove:
del self._data[k]
if self._max_size > 0 and k in self._access_order:
self._access_order.remove(k)
@staticmethod
def _key_starts_with(key: tuple, prefix: tuple) -> bool:
"""Check if key starts with prefix (both tuples)."""
if len(key) < len(prefix):
return False
return key[: len(prefix)] == prefix
# Shared caches for duty-related data. Invalidate on import.
ics_calendar_cache = TTLCache(ttl_seconds=600, max_size=500)
duty_pin_cache = TTLCache(ttl_seconds=90, max_size=100) # current_duty, next_shift_end
is_admin_cache = TTLCache(ttl_seconds=60, max_size=200)
def invalidate_duty_related_caches() -> None:
"""Invalidate caches that depend on duties data. Call after import."""
ics_calendar_cache.invalidate_pattern(("personal_ics",))
ics_calendar_cache.invalidate_pattern(("team_ics",))
duty_pin_cache.invalidate_pattern(("duty_message_text",))
duty_pin_cache.invalidate(("next_shift_end",))

View File

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

View File

@@ -84,3 +84,13 @@ class GroupDutyPin(Base):
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
message_id: Mapped[int] = mapped_column(Integer, nullable=False)
class TrustedGroup(Base):
"""Groups authorized to receive duty information."""
__tablename__ = "trusted_groups"
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
added_by_user_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
added_at: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -11,6 +11,7 @@ from duty_teller.db.models import (
User,
Duty,
GroupDutyPin,
TrustedGroup,
CalendarSubscriptionToken,
Role,
)
@@ -229,6 +230,22 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
return user
def get_users_by_full_names(session: Session, full_names: list[str]) -> dict[str, User]:
"""Get users by full_name. Returns dict full_name -> User. Does not create missing.
Args:
session: DB session.
full_names: List of full names to look up.
Returns:
Dict mapping full_name to User for found users.
"""
if not full_names:
return {}
users = session.query(User).filter(User.full_name.in_(full_names)).all()
return {u.full_name: u for u in users}
def update_user_display_name(
session: Session,
telegram_user_id: int,
@@ -268,6 +285,8 @@ def delete_duties_in_range(
user_id: int,
from_date: str,
to_date: str,
*,
commit: bool = True,
) -> int:
"""Delete all duties of the user that overlap the given date range.
@@ -276,6 +295,7 @@ def delete_duties_in_range(
user_id: User id.
from_date: Start date YYYY-MM-DD.
to_date: End date YYYY-MM-DD.
commit: If True, commit immediately. If False, caller commits (for batch import).
Returns:
Number of duties deleted.
@@ -288,7 +308,8 @@ def delete_duties_in_range(
)
count = q.count()
q.delete(synchronize_session=False)
session.commit()
if commit:
session.commit()
return count
@@ -296,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.
@@ -305,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)
)
@@ -322,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").
@@ -336,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 = [
@@ -347,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)
)
@@ -573,6 +594,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
return [r[0] for r in rows]
def is_trusted_group(session: Session, chat_id: int) -> bool:
"""Check if the chat is in the trusted groups list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return (
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
is not None
)
def add_trusted_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> TrustedGroup:
"""Add a group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
Returns:
Created TrustedGroup instance.
"""
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
record = TrustedGroup(
chat_id=chat_id,
added_by_user_id=added_by_user_id,
added_at=now_iso,
)
session.add(record)
session.commit()
session.refresh(record)
return record
def remove_trusted_group(session: Session, chat_id: int) -> None:
"""Remove a group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
session.commit()
def get_all_trusted_group_ids(session: Session) -> list[int]:
"""Return all chat_ids that are trusted.
Args:
session: DB session.
Returns:
List of trusted chat ids.
"""
rows = session.query(TrustedGroup.chat_id).all()
return [r[0] for r in rows]
def set_user_phone(
session: Session, telegram_user_id: int, phone: str | None
) -> User | None:

View File

@@ -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)

View File

@@ -22,4 +22,6 @@ def register_handlers(app: Application) -> None:
app.add_handler(group_duty_pin.group_duty_pin_handler)
app.add_handler(group_duty_pin.pin_duty_handler)
app.add_handler(group_duty_pin.refresh_pin_handler)
app.add_handler(group_duty_pin.trust_group_handler)
app.add_handler(group_duty_pin.untrust_group_handler)
app.add_error_handler(errors.error_handler)

View File

@@ -19,7 +19,7 @@ from duty_teller.db.repository import (
ROLE_USER,
ROLE_ADMIN,
)
from duty_teller.handlers.common import is_admin_async
from duty_teller.handlers.common import invalidate_is_admin_cache, is_admin_async
from duty_teller.i18n import get_lang, t
from duty_teller.utils.user import build_full_name
@@ -168,6 +168,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if await is_admin_async(update.effective_user.id):
lines.append(t(lang, "help.import_schedule"))
lines.append(t(lang, "help.set_role"))
lines.append(t(lang, "help.trust_group"))
lines.append(t(lang, "help.untrust_group"))
await update.message.reply_text("\n".join(lines))
@@ -230,6 +232,7 @@ async def set_role(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
ok = await asyncio.get_running_loop().run_in_executor(None, do_set_role)
if ok:
invalidate_is_admin_cache(target_user.telegram_user_id)
await update.message.reply_text(
t(lang, "set_role.done", name=target_user.full_name, role=role_name)
)

View File

@@ -3,22 +3,35 @@
import asyncio
import duty_teller.config as config
from duty_teller.cache import is_admin_cache
from duty_teller.db.repository import is_admin_for_telegram_user
from duty_teller.db.session import session_scope
async def is_admin_async(telegram_user_id: int) -> bool:
"""Check if Telegram user is admin (username or phone). Runs DB check in executor.
"""Check if Telegram user is admin. Cached 60s. Invalidated on set_user_role.
Args:
telegram_user_id: Telegram user id.
Returns:
True if user is in ADMIN_USERNAMES or their stored phone is in ADMIN_PHONES.
True if user is admin (DB role or env fallback).
"""
cache_key = ("is_admin", telegram_user_id)
value, found = is_admin_cache.get(cache_key)
if found:
return value
def _check() -> bool:
with session_scope(config.DATABASE_URL) as session:
return is_admin_for_telegram_user(session, telegram_user_id)
return await asyncio.get_running_loop().run_in_executor(None, _check)
result = await asyncio.get_running_loop().run_in_executor(None, _check)
is_admin_cache.set(cache_key, result)
return result
def invalidate_is_admin_cache(telegram_user_id: int | None) -> None:
"""Invalidate is_admin cache for user. Call after set_user_role."""
if telegram_user_id is not None:
is_admin_cache.invalidate(("is_admin", telegram_user_id))

View File

@@ -6,20 +6,25 @@ from datetime import datetime, timezone
from typing import Literal
import duty_teller.config as config
from telegram import Update
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
from duty_teller.db.session import session_scope
from duty_teller.i18n import get_lang, t
from duty_teller.handlers.common import is_admin_async
from duty_teller.services.group_duty_pin_service import (
get_duty_message_text,
get_message_id,
get_next_shift_end_utc,
get_pin_refresh_data,
save_pin,
delete_pin,
get_message_id,
get_all_pin_chat_ids,
is_group_trusted,
trust_group,
untrust_group,
)
logger = logging.getLogger(__name__)
@@ -28,6 +33,14 @@ JOB_NAME_PREFIX = "duty_pin_"
RETRY_WHEN_NO_DUTY_MINUTES = 15
def _sync_get_pin_refresh_data(
chat_id: int, lang: str = "en"
) -> tuple[int | None, str, datetime | None]:
"""Get message_id, duty text, next_shift_end in one DB session."""
with session_scope(config.DATABASE_URL) as session:
return get_pin_refresh_data(session, chat_id, config.DUTY_DISPLAY_TZ, lang)
def _get_duty_message_text_sync(lang: str = "en") -> str:
with session_scope(config.DATABASE_URL) as session:
return get_duty_message_text(session, config.DUTY_DISPLAY_TZ, lang)
@@ -53,6 +66,54 @@ def _sync_get_message_id(chat_id: int) -> int | None:
return get_message_id(session, chat_id)
def _sync_is_trusted(chat_id: int) -> bool:
"""Check if the group is trusted (sync wrapper for handlers)."""
with session_scope(config.DATABASE_URL) as session:
return is_group_trusted(session, chat_id)
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
"""Add group to trusted list. Returns True if already trusted (no-op)."""
with session_scope(config.DATABASE_URL) as session:
if is_group_trusted(session, chat_id):
return True
trust_group(session, chat_id, added_by_user_id)
return False
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
Telegram returns Button_type_invalid. A plain URL button works everywhere.
"""
if not config.BOT_USERNAME:
return None
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
button = InlineKeyboardButton(
text=t(lang, "pin_duty.view_contacts"),
url=url,
)
return InlineKeyboardMarkup([[button]])
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
"""Remove group from trusted list.
Returns:
(was_trusted, message_id): was_trusted False if group was not in list;
message_id of pinned message if any (for cleanup), else None.
"""
with session_scope(config.DATABASE_URL) as session:
if not is_group_trusted(session, chat_id):
return (False, None)
message_id = get_message_id(session, chat_id)
delete_pin(session, chat_id)
untrust_group(session, chat_id)
return (True, message_id)
async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None
) -> None:
@@ -93,29 +154,58 @@ async def _schedule_next_update(
async def _refresh_pin_for_chat(
context: ContextTypes.DEFAULT_TYPE, chat_id: int
) -> Literal["updated", "no_message", "failed"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
) -> Literal["updated", "no_message", "failed", "untrusted"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
Uses single DB session for message_id, text, next_shift_end (consolidated).
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
Returns:
"updated" if the message was sent, pinned and saved successfully;
"no_message" if there is no pin record for this chat;
"failed" if send_message or permissions failed.
"failed" if send_message or permissions failed;
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
"""
loop = asyncio.get_running_loop()
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if old_message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(
chat_id=chat_id, message_id=old_message_id
)
except (BadRequest, Forbidden):
pass
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
return "untrusted"
message_id, text, next_end = await loop.run_in_executor(
None,
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
)
if message_id is None:
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return "no_message"
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(config.DEFAULT_LANGUAGE)
)
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
)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
try:
@@ -127,11 +217,19 @@ async def _refresh_pin_for_chat(
)
except (BadRequest, Forbidden) as e:
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
if old_message_id is not None:
try:
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
except (BadRequest, Forbidden) as e:
logger.warning(
"Could not delete old pinned message %s in chat_id=%s: %s",
old_message_id,
chat_id,
e,
)
await _schedule_next_update(context.application, chat_id, next_end)
return "updated"
@@ -167,12 +265,27 @@ async def my_chat_member_handler(
ChatMemberStatus.BANNED,
):
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
lang = get_lang(update.effective_user)
try:
await context.bot.send_message(
chat_id=chat_id,
text=t(lang, "group.not_trusted"),
)
except (BadRequest, Forbidden):
pass
return
lang = get_lang(update.effective_user)
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return
@@ -236,9 +349,48 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
await update.message.reply_text(t(lang, "pin_duty.no_message"))
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
)
await update.message.reply_text(t(lang, "pin_duty.failed"))
return
pinned = False
try:
await context.bot.pin_chat_message(
chat_id=chat_id,
message_id=msg.message_id,
disable_notification=True,
)
pinned = True
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
if pinned:
await update.message.reply_text(t(lang, "pin_duty.pinned"))
else:
await update.message.reply_text(
t(lang, "pin_duty.could_not_pin_make_admin")
)
return
try:
await context.bot.pin_chat_message(
@@ -262,13 +414,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
result = await _refresh_pin_for_chat(context, chat_id)
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /trust_group: add current group to trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "trust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
already_trusted = await loop.run_in_executor(
None,
lambda: _sync_trust_group(
chat_id, update.effective_user.id if update.effective_user else None
),
)
if already_trusted:
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
return
await update.message.reply_text(t(lang, "trust_group.added"))
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message after trust_group chat_id=%s: %s",
chat_id,
e,
)
return
try:
await context.bot.pin_chat_message(
chat_id=chat_id,
message_id=msg.message_id,
disable_notification=not config.DUTY_PIN_NOTIFY,
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "untrust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
was_trusted, message_id = await loop.run_in_executor(
None, _sync_untrust_group, chat_id
)
if not was_trusted:
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
return
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except (BadRequest, Forbidden):
pass
await update.message.reply_text(t(lang, "untrust_group.removed"))
group_duty_pin_handler = ChatMemberHandler(
my_chat_member_handler,
ChatMemberHandler.MY_CHAT_MEMBER,
)
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)

View File

@@ -18,6 +18,19 @@ MESSAGES: dict[str, dict[str, str]] = {
"refresh_pin.no_message": "There is no pinned duty message to refresh in this chat.",
"refresh_pin.updated": "Pinned duty message updated.",
"refresh_pin.failed": "Could not update the pinned message (permissions or edit error).",
"refresh_pin.untrusted": "Group was removed from trusted list; pin record cleared.",
"trust_group.added": "Group added to trusted list.",
"trust_group.already_trusted": "This group is already trusted.",
"trust_group.group_only": "The /trust_group command works only in groups.",
"untrust_group.removed": "Group removed from trusted list.",
"untrust_group.not_trusted": "This group is not in the trusted list.",
"untrust_group.group_only": "The /untrust_group command works only in groups.",
"group.not_trusted": (
"This group is not authorized to receive duty data. "
"An administrator can add the group with /trust_group."
),
"help.trust_group": "/trust_group — In a group: add group to trusted list (admin only)",
"help.untrust_group": "/untrust_group — In a group: remove group from trusted list (admin only)",
"calendar_link.private_only": "The /calendar_link command is only available in private chat.",
"calendar_link.access_denied": "Access denied.",
"calendar_link.success": (
@@ -47,6 +60,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"administrator with «Pin messages» permission, then send /pin_duty in the "
"chat — the current message will be pinned."
),
"pin_duty.view_contacts": "View contacts",
"duty.no_duty": "No duty at the moment.",
"duty.label": "Duty:",
"import.admin_only": "Access for administrators only.",
@@ -74,6 +88,13 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Access denied",
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
"dates.from_after_to": "from date must not be after to",
"contact.show": "Contacts",
"contact.back": "Back",
"current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift",
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar",
},
"ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -92,6 +113,19 @@ MESSAGES: dict[str, dict[str, str]] = {
"refresh_pin.no_message": "В этом чате нет закреплённого сообщения о дежурстве для обновления.",
"refresh_pin.updated": "Закреплённое сообщение о дежурстве обновлено.",
"refresh_pin.failed": "Не удалось обновить закреплённое сообщение (права или ошибка редактирования).",
"refresh_pin.untrusted": "Группа удалена из доверенных; запись о закреплении сброшена.",
"trust_group.added": "Группа добавлена в доверенные.",
"trust_group.already_trusted": "Эта группа уже в доверенных.",
"trust_group.group_only": "Команда /trust_group работает только в группах.",
"untrust_group.removed": "Группа удалена из доверенных.",
"untrust_group.not_trusted": "Эта группа не в доверенных.",
"untrust_group.group_only": "Команда /untrust_group работает только в группах.",
"group.not_trusted": (
"Эта группа не авторизована для получения данных дежурных. "
"Администратор может добавить группу командой /trust_group."
),
"help.trust_group": "/trust_group — В группе: добавить группу в доверенные (только админ)",
"help.untrust_group": "/untrust_group — В группе: удалить группу из доверенных (только админ)",
"calendar_link.private_only": "Команда /calendar_link доступна только в личке.",
"calendar_link.access_denied": "Доступ запрещён.",
"calendar_link.success": (
@@ -116,6 +150,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"pin_duty.could_not_pin_make_admin": "Сообщение о дежурстве отправлено, но закрепить его не удалось. "
"Сделайте бота администратором с правом «Закреплять сообщения» (Pin messages), "
"затем отправьте в чат команду /pin_duty — текущее сообщение будет закреплено.",
"pin_duty.view_contacts": "Контакты",
"duty.no_duty": "Сейчас дежурства нет.",
"duty.label": "Дежурство:",
"import.admin_only": "Доступ только для администраторов.",
@@ -136,5 +171,12 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Доступ запрещён",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to",
"contact.show": "Контакты",
"contact.back": "Назад",
"current_duty.title": "Текущее дежурство",
"current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю",
},
}

View File

@@ -13,6 +13,15 @@ from duty_teller.config import require_bot_token
from duty_teller.handlers import group_duty_pin, register_handlers
from duty_teller.utils.http_client import safe_urlopen
async def _resolve_bot_username(application) -> None:
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
if not config.BOT_USERNAME:
me = await application.bot.get_me()
config.BOT_USERNAME = (me.username or "").lower()
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
@@ -69,6 +78,7 @@ def main() -> None:
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build()
)
register_handlers(app)

View File

@@ -5,6 +5,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy.orm import Session
from duty_teller.cache import duty_pin_cache
from duty_teller.db.repository import (
get_current_duty,
get_next_shift_end,
@@ -12,11 +13,39 @@ from duty_teller.db.repository import (
save_group_duty_pin,
delete_group_duty_pin,
get_all_group_duty_pin_chat_ids,
is_trusted_group,
add_trusted_group,
remove_trusted_group,
)
from duty_teller.i18n import t
from duty_teller.utils.dates import parse_utc_iso
def get_pin_refresh_data(
session: Session, chat_id: int, tz_name: str, lang: str = "en"
) -> tuple[int | None, str, datetime | None]:
"""Get all data needed for pin refresh in a single DB session.
Args:
session: DB session.
chat_id: Telegram chat id.
tz_name: Timezone name for display.
lang: Language code for i18n.
Returns:
(message_id, duty_message_text, next_shift_end_utc).
message_id is None if no pin record. next_shift_end_utc is naive UTC or None.
"""
pin = get_group_duty_pin(session, chat_id)
message_id = pin.message_id if pin else None
if message_id is None:
return (None, t(lang, "duty.no_duty"), None)
now = datetime.now(timezone.utc)
text = get_duty_message_text(session, tz_name, lang)
next_end = get_next_shift_end(session, now)
return (message_id, text, next_end)
def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
"""Build the text for the pinned duty message.
@@ -56,42 +85,35 @@ 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)
def get_duty_message_text(session: Session, tz_name: str, lang: str = "en") -> str:
"""Get current duty from DB and return formatted message text.
Args:
session: DB session.
tz_name: Timezone name for display.
lang: Language code for i18n.
Returns:
Formatted duty message or "No duty" if none.
"""
"""Get current duty from DB and return formatted message text. Cached 90s."""
cache_key = ("duty_message_text", tz_name, lang)
text, found = duty_pin_cache.get(cache_key)
if found:
return text
now = datetime.now(timezone.utc)
result = get_current_duty(session, now)
if result is None:
return t(lang, "duty.no_duty")
duty, user = result
return format_duty_message(duty, user, tz_name, lang)
text = t(lang, "duty.no_duty")
else:
duty, user = result
text = format_duty_message(duty, user, tz_name, lang)
duty_pin_cache.set(cache_key, text)
return text
def get_next_shift_end_utc(session: Session) -> datetime | None:
"""Return next shift end as naive UTC datetime for job scheduling.
Args:
session: DB session.
Returns:
Next shift end (naive UTC) or None.
"""
return get_next_shift_end(session, datetime.now(timezone.utc))
"""Return next shift end as naive UTC datetime for job scheduling. Cached 90s."""
cache_key = ("next_shift_end",)
value, found = duty_pin_cache.get(cache_key)
if found:
return value
result = get_next_shift_end(session, datetime.now(timezone.utc))
duty_pin_cache.set(cache_key, result)
return result
def save_pin(session: Session, chat_id: int, message_id: int) -> None:
@@ -141,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
List of chat ids.
"""
return get_all_group_duty_pin_chat_ids(session)
def is_group_trusted(session: Session, chat_id: int) -> bool:
"""Check if the group is in the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return is_trusted_group(session, chat_id)
def trust_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> None:
"""Add the group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
"""
add_trusted_group(session, chat_id, added_by_user_id)
def untrust_group(session: Session, chat_id: int) -> None:
"""Remove the group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
remove_trusted_group(session, chat_id)

View File

@@ -4,10 +4,12 @@ from datetime import date, timedelta
from sqlalchemy.orm import Session
from duty_teller.cache import invalidate_duty_related_caches
from duty_teller.db.models import Duty
from duty_teller.db.repository import (
get_or_create_user_by_full_name,
delete_duties_in_range,
insert_duty,
get_or_create_user_by_full_name,
get_users_by_full_names,
)
from duty_teller.importers.duty_schedule import DutyScheduleResult
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
@@ -37,11 +39,10 @@ def run_import(
hour_utc: int,
minute_utc: int,
) -> tuple[int, int, int, int]:
"""Run duty-schedule import: delete range per user, insert duty/unavailable/vacation.
"""Run duty-schedule import: delete range per user, bulk insert duties.
For each entry: get_or_create_user_by_full_name, delete_duties_in_range for
the result date range, then insert duties (handover time in UTC), unavailable
(all-day), and vacation (consecutive ranges).
Batched: users fetched in one query, missing created; bulk_insert_mappings.
One commit at end.
Args:
session: DB session.
@@ -55,31 +56,61 @@ def run_import(
from_date_str = result.start_date.isoformat()
to_date_str = result.end_date.isoformat()
num_duty = num_unavailable = num_vacation = 0
# Batch: get all users by full_name, create missing
names = [e.full_name for e in result.entries]
users_map = get_users_by_full_names(session, names)
for name in names:
if name not in users_map:
users_map[name] = get_or_create_user_by_full_name(session, name)
# Delete range per user (no commit)
for entry in result.entries:
user = get_or_create_user_by_full_name(session, entry.full_name)
delete_duties_in_range(session, user.id, from_date_str, to_date_str)
user = users_map[entry.full_name]
delete_duties_in_range(
session, user.id, from_date_str, to_date_str, commit=False
)
# Build rows for bulk insert
duty_rows: list[dict] = []
for entry in result.entries:
user = users_map[entry.full_name]
for d in entry.duty_dates:
start_at = duty_to_iso(d, hour_utc, minute_utc)
d_next = d + timedelta(days=1)
end_at = duty_to_iso(d_next, hour_utc, minute_utc)
insert_duty(session, user.id, start_at, end_at, event_type="duty")
duty_rows.append(
{
"user_id": user.id,
"start_at": start_at,
"end_at": end_at,
"event_type": "duty",
}
)
num_duty += 1
for d in entry.unavailable_dates:
insert_duty(
session,
user.id,
day_start_iso(d),
day_end_iso(d),
event_type="unavailable",
duty_rows.append(
{
"user_id": user.id,
"start_at": day_start_iso(d),
"end_at": day_end_iso(d),
"event_type": "unavailable",
}
)
num_unavailable += 1
for start_d, end_d in _consecutive_date_ranges(entry.vacation_dates):
insert_duty(
session,
user.id,
day_start_iso(start_d),
day_end_iso(end_d),
event_type="vacation",
duty_rows.append(
{
"user_id": user.id,
"start_at": day_start_iso(start_d),
"end_at": day_end_iso(end_d),
"event_type": "vacation",
}
)
num_vacation += 1
if duty_rows:
session.bulk_insert_mappings(Duty, duty_rows)
session.commit()
invalidate_duty_related_caches()
return (len(result.entries), num_duty, num_unavailable, num_vacation)

View File

@@ -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"

View File

@@ -254,7 +254,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
)
def fake_get_duties(session, from_date, to_date):
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"
)
@@ -403,6 +411,9 @@ def test_calendar_ical_ignores_unknown_query_params(
"""Unknown query params (e.g. events=all) are ignored; response is duty-only."""
from types import SimpleNamespace
from duty_teller.cache import ics_calendar_cache
ics_calendar_cache.invalidate(("personal_ics", 1))
mock_user = SimpleNamespace(id=1, full_name="User A")
mock_get_user.return_value = mock_user
duty = SimpleNamespace(
@@ -412,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

View File

@@ -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,15 +95,17 @@ 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:
"""Tests for get_duty_message_text."""
def test_no_current_duty_returns_no_duty(self, session):
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
with patch(
"duty_teller.services.group_duty_pin_service.get_current_duty",
return_value=None,
@@ -113,6 +116,7 @@ class TestGetDutyMessageText:
assert result == "No duty"
def test_with_current_duty_returns_formatted(self, session, duty, user):
svc.duty_pin_cache.invalidate_pattern(("duty_message_text",))
with patch(
"duty_teller.services.group_duty_pin_service.get_current_duty",
return_value=(duty, user),
@@ -130,6 +134,7 @@ class TestGetNextShiftEndUtc:
"""Tests for get_next_shift_end_utc."""
def test_no_next_shift_returns_none(self, session):
svc.duty_pin_cache.invalidate(("next_shift_end",))
with patch(
"duty_teller.services.group_duty_pin_service.get_next_shift_end",
return_value=None,
@@ -138,6 +143,7 @@ class TestGetNextShiftEndUtc:
assert result is None
def test_has_next_shift_returns_naive_utc(self, session):
svc.duty_pin_cache.invalidate(("next_shift_end",))
naive = datetime(2025, 2, 21, 6, 0, 0)
with patch(
"duty_teller.services.group_duty_pin_service.get_next_shift_end",

View File

@@ -11,6 +11,13 @@ import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod
@pytest.fixture(autouse=True)
def no_mini_app_url():
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
with patch.object(config, "BOT_USERNAME", ""):
yield
class TestSyncWrappers:
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
@@ -76,6 +83,30 @@ class TestSyncWrappers:
# --- _schedule_next_update ---
def test_get_contact_button_markup_empty_username_returns_none():
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
with patch.object(config, "BOT_USERNAME", ""):
assert mod._get_contact_button_markup("en") is None
def test_get_contact_button_markup_returns_markup_when_username_set():
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
from telegram import InlineKeyboardMarkup
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
with patch.object(mod, "t", return_value="View contacts"):
result = mod._get_contact_button_markup("en")
assert result is not None
assert isinstance(result, InlineKeyboardMarkup)
assert len(result.inline_keyboard) == 1
assert len(result.inline_keyboard[0]) == 1
btn = result.inline_keyboard[0][0]
assert btn.text == "View contacts"
assert btn.url.startswith("https://t.me/")
assert "startapp=duty" in btn.url
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
@pytest.mark.asyncio
async def test_schedule_next_update_job_queue_none_returns_early():
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
@@ -127,7 +158,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
@pytest.mark.asyncio
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
@@ -137,26 +168,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_get_message_id", return_value=1):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Current duty"
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=123, text="Current duty", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=123, message_id=999, disable_notification=False
)
mock_save.assert_called_once_with(123, 999)
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
@pytest.mark.asyncio
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
context.job = MagicMock()
context.job.data = {"chat_id": 123}
context.bot = MagicMock()
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock(
side_effect=BadRequest("Message to delete not found")
)
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
mock_save.assert_called_once_with(123, 999)
mock_schedule.assert_called_once_with(context.application, 123, None)
mock_logger.warning.assert_called_once()
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
@pytest.mark.asyncio
@@ -168,8 +243,11 @@ async def test_update_group_pin_no_message_id_skips():
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch.object(mod, "_sync_get_message_id", return_value=None):
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
context.bot.send_message.assert_not_called()
@@ -185,13 +263,14 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
with patch.object(mod, "_sync_get_message_id", return_value=2):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
context.bot.unpin_chat_message.assert_not_called()
context.bot.pin_chat_message.assert_not_called()
mock_schedule.assert_called_once_with(context.application, 111, None)
@@ -214,16 +293,19 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_get_message_id", return_value=3):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=222, text="Text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=222, text="Text", reply_markup=None
)
mock_save.assert_not_called()
mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
@@ -232,7 +314,7 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
@pytest.mark.asyncio
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
new_msg = MagicMock()
new_msg.message_id = 777
context = MagicMock()
@@ -242,23 +324,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", False):
with patch.object(mod, "_sync_get_message_id", return_value=4):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Text"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=333, text="Text", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=333, message_id=777, disable_notification=True
)
mock_save.assert_called_once_with(333, 777)
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
mock_schedule.assert_called_once_with(context.application, 333, None)
@@ -297,10 +384,11 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
context.bot.pin_chat_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=5, disable_notification=True
)
@@ -308,8 +396,8 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_replies_no_message():
"""pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message."""
async def test_pin_duty_cmd_untrusted_group_rejects():
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
@@ -318,14 +406,150 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message():
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message to pin"
mock_t.return_value = "Not authorized"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message to pin")
mock_t.assert_called_with("en", "pin_duty.no_message")
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 42
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=42, disable_notification=True
)
mock_save.assert_called_once_with(100, 42)
update.message.reply_text.assert_called_once_with("Pinned")
mock_t.assert_called_with("en", "pin_duty.pinned")
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
context.application = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Failed"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "pin_duty.failed")
mock_save.assert_not_called()
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 43
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty", reply_markup=None
)
mock_save.assert_called_once_with(100, 43)
update.message.reply_text.assert_called_once_with("Make me admin to pin")
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
@pytest.mark.asyncio
@@ -345,10 +569,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
)
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed to pin")
mock_t.assert_called_with("en", "pin_duty.failed")
@@ -389,12 +614,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Updated")
mock_t.assert_called_with("en", "refresh_pin.updated")
@@ -412,12 +638,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message")
mock_t.assert_called_with("en", "refresh_pin.no_message")
@@ -435,16 +662,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "refresh_pin.failed")
@pytest.mark.asyncio
async def test_refresh_pin_cmd_untrusted_group_rejects():
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock()
) as mock_refresh:
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
mock_refresh.assert_not_called()
# --- my_chat_member_handler ---
@@ -493,12 +746,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@pytest.mark.asyncio
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
@pytest.mark.asyncio
async def test_my_chat_member_handler_trusted_group_sends_duty():
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@@ -524,13 +845,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
assert context.bot.send_message.call_count >= 2
pin_hint_calls = [
c
@@ -581,3 +907,196 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
assert mock_schedule.call_count == 2
mock_schedule.assert_any_call(application, 10, None)
mock_schedule.assert_any_call(application, 20, None)
# --- _refresh_pin_for_chat untrusted ---
@pytest.mark.asyncio
async def test_refresh_pin_for_chat_untrusted_removes_pin():
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
mock_job = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=11):
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
result = await mod._refresh_pin_for_chat(context, 100)
assert result == "untrusted"
mock_delete_pin.assert_called_once_with(100)
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
# --- trust_group_cmd / untrust_group_cmd ---
@pytest.mark.asyncio
async def test_trust_group_cmd_non_admin_rejects():
"""trust_group_cmd: non-admin -> reply import.admin_only."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Admin only"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Admin only")
mock_t.assert_called_with("en", "import.admin_only")
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_adds_group():
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 50
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Added"
with patch.object(
config, "DUTY_PIN_NOTIFY", False
):
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_any_call("Added")
mock_t.assert_any_call("en", "trust_group.added")
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=50, disable_notification=True
)
mock_save.assert_called_once_with(100, 50)
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=True):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Already trusted"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Already trusted")
mock_t.assert_called_with("en", "trust_group.already_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_untrust_group_cmd_removes_group():
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
mock_job = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Removed"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Removed")
mock_t.assert_called_with("en", "untrust_group.removed")
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
@pytest.mark.asyncio
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not trusted"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not trusted")
mock_t.assert_called_with("en", "untrust_group.not_trusted")

View File

@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
assert "2026-02-16T06:00:00Z" in starts
assert "2026-02-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"

View File

@@ -0,0 +1,84 @@
"""Unit tests for trusted_groups repository functions."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from duty_teller.db.models import Base
from duty_teller.db.repository import (
is_trusted_group,
add_trusted_group,
remove_trusted_group,
get_all_trusted_group_ids,
)
@pytest.fixture
def session():
"""In-memory SQLite session with all tables (including trusted_groups)."""
engine = create_engine(
"sqlite:///:memory:", connect_args={"check_same_thread": False}
)
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine, autocommit=False, autoflush=False)
s = Session()
try:
yield s
finally:
s.close()
engine.dispose()
def test_is_trusted_group_empty_returns_false(session):
"""is_trusted_group returns False when no record exists."""
assert is_trusted_group(session, 100) is False
assert is_trusted_group(session, 200) is False
def test_add_trusted_group_creates_record(session):
"""add_trusted_group creates a record and returns TrustedGroup."""
record = add_trusted_group(session, 100, added_by_user_id=12345)
assert record.chat_id == 100
assert record.added_by_user_id == 12345
assert record.added_at is not None
def test_is_trusted_group_after_add_returns_true(session):
"""is_trusted_group returns True after add_trusted_group."""
add_trusted_group(session, 100)
assert is_trusted_group(session, 100) is True
assert is_trusted_group(session, 101) is False
def test_add_trusted_group_without_added_by_user_id(session):
"""add_trusted_group accepts added_by_user_id None."""
record = add_trusted_group(session, 200, added_by_user_id=None)
assert record.chat_id == 200
assert record.added_by_user_id is None
def test_remove_trusted_group_removes_record(session):
"""remove_trusted_group removes the record."""
add_trusted_group(session, 100)
assert is_trusted_group(session, 100) is True
remove_trusted_group(session, 100)
assert is_trusted_group(session, 100) is False
def test_remove_trusted_group_idempotent(session):
"""remove_trusted_group on non-existent chat_id does not raise."""
remove_trusted_group(session, 999)
def test_get_all_trusted_group_ids_empty(session):
"""get_all_trusted_group_ids returns empty list when no trusted groups."""
assert get_all_trusted_group_ids(session) == []
def test_get_all_trusted_group_ids_returns_added_chats(session):
"""get_all_trusted_group_ids returns all trusted chat_ids."""
add_trusted_group(session, 10)
add_trusted_group(session, 20)
add_trusted_group(session, 30)
ids = get_all_trusted_group_ids(session)
assert set(ids) == {10, 20, 30}

94
webapp/css/base.css Normal file
View File

@@ -0,0 +1,94 @@
/* === Variables & themes */
:root {
--bg: #1a1b26;
--surface: #24283b;
--text: #c0caf5;
--muted: #565f89;
--accent: #7aa2f7;
--duty: #9ece6a;
--today: #bb9af7;
--unavailable: #e0af68;
--vacation: #7dcfff;
--error: #f7768e;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
[data-theme="light"] {
--bg: var(--tg-theme-bg-color, #f0f1f3);
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
--text: var(--tg-theme-text-color, #343b58);
--muted: var(--tg-theme-hint-color, #6b7089);
--accent: var(--tg-theme-link-color, #2e7de0);
--duty: #587d0a;
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
--error: #c43b3b;
}
/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
[data-theme="dark"] {
--bg: var(--tg-theme-bg-color, #17212b);
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
--text: var(--tg-theme-text-color, #f5f5f5);
--muted: var(--tg-theme-hint-color, #708499);
--accent: var(--tg-theme-link-color, #6ab3f3);
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
--duty: #5c9b4a;
--unavailable: #b8860b;
--vacation: #5a9bb8;
--error: #e06c75;
}
/* === Layout & base */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.container {
max-width: 420px;
margin: 0 auto;
padding: 12px;
padding-top: 0px;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
[data-theme="light"] .container {
border-radius: 12px;
}

197
webapp/css/calendar.css Normal file
View File

@@ -0,0 +1,197 @@
/* === Calendar: header, nav, weekdays, grid, day cells */
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header[hidden],
.weekdays[hidden] {
display: none !important;
}
.nav {
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--surface);
color: var(--accent);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav:focus {
outline: none;
}
.nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.nav:active {
transform: scale(0.95);
}
.nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 6px;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
}
.calendar-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding-bottom: 12px;
margin-bottom: 4px;
touch-action: pan-y;
transition: box-shadow var(--transition-fast) ease-out;
}
.calendar-sticky.is-scrolled {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 16px;
}
.day {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px;
border-radius: 8px;
font-size: 0.85rem;
background: var(--surface);
min-width: 0;
min-height: 0;
overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.day.other-month {
opacity: 0.4;
}
.day.today {
background: var(--today);
color: var(--bg);
}
.day.has-duty .num {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
.day.today.holiday {
background: var(--today);
color: var(--bg);
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
}
.day {
cursor: pointer;
}
.day:focus {
outline: none;
}
.day:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day:active {
transform: scale(0.98);
}
.day-indicator {
display: flex;
justify-content: center;
width: 65%;
margin-top: 6px;
}
.day-indicator-dot {
flex: 1;
height: 5px;
border-radius: 0;
}
.day-indicator-dot:only-child {
flex: 0 0 6px;
height: 6px;
border-radius: 50%;
}
.day-indicator-dot:first-child:not(:only-child) {
border-radius: 3px 0 0 3px;
}
.day-indicator-dot:last-child:not(:only-child) {
border-radius: 0 3px 3px 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* On "today" cell: dots darkened for contrast on --today background */
.day.today .day-indicator-dot.duty {
background: color-mix(in srgb, var(--duty) 65%, var(--bg));
}
.day.today .day-indicator-dot.unavailable {
background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
}
.day.today .day-indicator-dot.vacation {
background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
}
.day.today .day-indicator-dot.events {
background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}

219
webapp/css/day-detail.css Normal file
View File

@@ -0,0 +1,219 @@
/* === Day detail panel (popover / bottom sheet) */
/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
body.day-detail-sheet-open {
position: fixed;
left: 0;
right: 0;
overflow: hidden;
}
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal) ease-out;
}
.day-detail-overlay.day-detail-overlay--visible {
opacity: 1;
pointer-events: auto;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
/* Комфортный отступ снизу: safe area + дополнительное поле */
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform var(--transition-normal) var(--ease-out);
}
.day-detail-panel--sheet.day-detail-panel--open {
transform: translateY(0);
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.day-detail-close:focus {
outline: none;
}
.day-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
/* Contact info: phone (tel:) and Telegram username links in day detail */
.day-detail-contact-row {
margin-top: 4px;
font-size: 0.85rem;
color: var(--muted);
}
.day-detail-contact {
display: inline-block;
margin-right: 0.75em;
}
.day-detail-contact:last-child {
margin-right: 0;
}
.day-detail-contact-link,
.day-detail-contact-phone,
.day-detail-contact-username {
color: var(--accent);
text-decoration: none;
}
.day-detail-contact-link:hover,
.day-detail-contact-phone:hover,
.day-detail-contact-username:hover {
text-decoration: underline;
}
.day-detail-contact-link:focus,
.day-detail-contact-phone:focus,
.day-detail-contact-username:focus {
outline: none;
}
.day-detail-contact-link:focus-visible,
.day-detail-contact-phone:focus-visible,
.day-detail-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.day-markers {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 2px;
align-items: center;
margin-top: 2px;
min-width: 0;
}

330
webapp/css/duty-list.css Normal file
View File

@@ -0,0 +1,330 @@
/* === Duty list & timeline */
.duty-list {
font-size: 0.9rem;
}
.duty-list h2 {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 8px 0;
}
.duty-list-day {
margin-bottom: 16px;
}
.duty-list-day--today .duty-list-day-title {
color: var(--today);
font-weight: 700;
}
.duty-list-day--today .duty-list-day-title::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
background: var(--today);
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
/* Timeline: dates | track (line + dot) | cards */
.duty-list.duty-timeline {
position: relative;
}
.duty-list.duty-timeline::before {
content: "";
position: absolute;
left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
top: 0;
bottom: 0;
width: 2px;
background: var(--muted);
pointer-events: none;
}
.duty-timeline-day {
margin-bottom: 0;
}
.duty-timeline-day--today {
scroll-margin-top: 200px;
}
.duty-timeline-row {
display: grid;
grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
gap: 0 4px;
align-items: start;
margin-bottom: 8px;
min-height: 1px;
}
.duty-timeline-date {
position: relative;
font-size: 0.8rem;
color: var(--muted);
padding-top: 10px;
padding-bottom: 10px;
flex-shrink: 0;
overflow: visible;
}
.duty-timeline-date::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
color-mix(in srgb, var(--muted) 40%, transparent) 0%,
color-mix(in srgb, var(--muted) 40%, transparent) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
.duty-timeline-day--today .duty-timeline-date {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: 4px;
color: var(--today);
font-weight: 600;
}
.duty-timeline-day--today .duty-timeline-date::before,
.duty-timeline-day--today .duty-timeline-date::after {
display: none;
}
.duty-timeline-date-label,
.duty-timeline-date-day {
display: block;
line-height: 1.25;
}
.duty-timeline-date-day {
align-self: flex-start;
text-align: left;
padding-left: 0;
margin-left: 0;
}
.duty-timeline-date-dot {
display: block;
width: 100%;
height: 8px;
min-height: 8px;
position: relative;
flex-shrink: 0;
}
.duty-timeline-date-dot::before {
content: "";
position: absolute;
left: 0;
top: 50%;
margin-top: -1px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: color-mix(in srgb, var(--today) 45%, transparent);
}
.duty-timeline-date-dot::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
top: 50%;
margin-top: -3px;
width: 2px;
height: 6px;
background: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
color: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
color: var(--muted);
font-weight: 400;
font-size: 0.75rem;
}
.duty-timeline-track {
min-width: 0;
}
.duty-timeline-card-wrap {
min-width: 0;
}
/* Flip-card: front = duty info + button, back = contacts */
.duty-flip-card {
perspective: 600px;
position: relative;
min-height: 0;
overflow: hidden;
border-radius: 8px;
background: transparent;
}
.duty-flip-inner {
transition: transform 0.4s;
transform-style: preserve-3d;
position: relative;
min-height: 0;
background: transparent;
}
.duty-flip-card[data-flipped="true"] .duty-flip-inner {
transform: rotateY(180deg);
}
.duty-flip-front {
position: relative;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.duty-flip-back {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
.duty-flip-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
padding: 0;
border: none;
border-radius: 50%;
background: var(--surface);
color: var(--accent);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background var(--transition-fast), color var(--transition-fast);
}
.duty-flip-btn:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--surface));
}
.duty-flip-btn:focus {
outline: none;
}
.duty-flip-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-timeline-card.duty-item,
.duty-list .duty-item {
display: grid;
grid-template-columns: 1fr;
gap: 2px 0;
align-items: baseline;
padding: 8px 10px;
margin-bottom: 0;
border-radius: 8px;
background: var(--surface);
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: var(--unavailable);
}
.duty-item--vacation {
border-left-color: var(--vacation);
}
.duty-item .duty-item-type {
grid-column: 1;
grid-row: 1;
font-size: 0.75rem;
color: var(--muted);
}
.duty-item .name {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
font-weight: 600;
}
.duty-item .time {
grid-column: 1;
grid-row: 2;
align-self: start;
font-size: 0.8rem;
color: var(--muted);
}
.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
/* Contact info: phone and Telegram username links in duty timeline cards */
.duty-contact-row {
grid-column: 1;
grid-row: 4;
font-size: 0.8rem;
color: var(--muted);
margin-top: 2px;
}
.duty-contact-link,
.duty-contact-phone,
.duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.duty-contact-link:hover,
.duty-contact-phone:hover,
.duty-contact-username:hover {
text-decoration: underline;
}
.duty-contact-link:focus,
.duty-contact-phone:focus,
.duty-contact-username:focus {
outline: none;
}
.duty-contact-link:focus-visible,
.duty-contact-phone:focus-visible,
.duty-contact-username:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.duty-item--current {
border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface));
}

70
webapp/css/hints.css Normal file
View File

@@ -0,0 +1,70 @@
/* === Hints (tooltips) */
.calendar-event-hint {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(98vw, 900px);
min-width: 0;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
overflow: visible;
transform: translateY(-100%);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.calendar-event-hint:not(.calendar-event-hint--visible) {
opacity: 0;
}
.calendar-event-hint.calendar-event-hint--visible {
opacity: 1;
}
.calendar-event-hint.below {
transform: none;
}
.calendar-event-hint-title {
margin-bottom: 4px;
font-weight: 600;
}
.calendar-event-hint-rows {
display: table;
width: min-content;
table-layout: auto;
border-collapse: separate;
border-spacing: 0 2px;
}
.calendar-event-hint-row {
display: table-row;
white-space: nowrap;
}
.calendar-event-hint-row .calendar-event-hint-time {
display: table-cell;
white-space: nowrap;
width: 1%;
vertical-align: top;
text-align: right;
padding-right: 0.15em;
}
.calendar-event-hint-row .calendar-event-hint-sep {
display: table-cell;
width: 1em;
vertical-align: top;
padding-right: 0.1em;
}
.calendar-event-hint-row .calendar-event-hint-name {
display: table-cell;
white-space: nowrap !important;
}

45
webapp/css/markers.css Normal file
View File

@@ -0,0 +1,45 @@
/* === Markers (duty / unavailable / vacation) */
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: box-shadow var(--transition-fast) ease-out;
}
.duty-marker {
color: var(--duty);
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.unavailable-marker {
color: var(--unavailable);
background: color-mix(in srgb, var(--unavailable) 25%, transparent);
}
.vacation-marker {
color: var(--vacation);
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}

306
webapp/css/states.css Normal file
View File

@@ -0,0 +1,306 @@
/* === Loading / error / access denied / current duty view */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
color: var(--muted);
text-align: center;
}
.loading__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading__spinner {
animation: none;
border-top-color: var(--accent);
border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.loading, .error {
text-align: center;
padding: 12px;
color: var(--muted);
}
.error,
.access-denied {
transition: opacity 0.2s ease-out;
}
.error {
color: var(--error);
}
.error[hidden], .loading.hidden,
.current-duty-view.hidden {
display: none !important;
}
/* Current duty view (Mini App deep link startapp=duty) */
[data-view="currentDuty"] .calendar-sticky,
[data-view="currentDuty"] .duty-list {
display: none !important;
}
.current-duty-view {
padding: 24px 16px;
min-height: 60vh;
display: flex;
align-items: center;
justify-content: center;
}
.current-duty-card {
background: var(--surface);
border-radius: 12px;
border-top: 3px solid var(--duty);
padding: 24px;
max-width: 360px;
width: 100%;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: card-appear 0.3s ease-out;
}
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.current-duty-title {
margin: 0 0 16px 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text);
}
.current-duty-live-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--duty);
margin-right: 8px;
animation: pulse-dot 1.5s ease-in-out infinite;
vertical-align: middle;
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.15);
}
}
@media (prefers-reduced-motion: reduce) {
.current-duty-card {
animation: none;
}
.current-duty-live-dot {
animation: none;
opacity: 1;
}
}
.current-duty-name {
margin: 0 0 8px 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--duty);
}
.current-duty-shift {
margin: 0 0 12px 0;
font-size: 0.95rem;
color: var(--muted);
}
.current-duty-remaining {
margin: 0 0 12px 0;
font-size: 0.95rem;
color: var(--muted);
}
/* No-duty state: icon + prominent text */
.current-duty-card--no-duty {
border-top-color: var(--muted);
}
.current-duty-no-duty-wrap {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
margin: 0 0 20px 0;
padding: 8px 0;
}
.current-duty-no-duty-icon {
display: block;
color: var(--muted);
margin-bottom: 16px;
line-height: 0;
}
.current-duty-no-duty-icon svg {
display: block;
opacity: 0.7;
}
.current-duty-no-duty {
margin: 0;
font-size: 1.15rem;
font-weight: 500;
color: var(--muted);
line-height: 1.4;
max-width: 20em;
}
.current-duty-error {
margin: 0 0 16px 0;
color: var(--error);
}
.current-duty-error {
color: var(--error);
}
.current-duty-contact-row {
margin: 12px 0 20px 0;
}
.current-duty-contact-row--blocks {
display: flex;
flex-direction: column;
gap: 8px;
}
.current-duty-contact-block {
display: flex;
align-items: center;
gap: 12px;
min-height: 48px;
padding: 12px 16px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: var(--accent);
text-decoration: none;
font-size: 1rem;
font-weight: 500;
transition: background 0.2s ease;
}
.current-duty-contact-block:hover,
.current-duty-contact-block:focus-visible {
background: color-mix(in srgb, var(--accent) 20%, transparent);
text-decoration: none;
color: var(--accent);
}
.current-duty-contact-block:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.current-duty-contact-block svg {
flex-shrink: 0;
}
.current-duty-contact {
display: inline-block;
margin-right: 12px;
font-size: 0.95rem;
}
.current-duty-contact-link,
.current-duty-contact-phone,
.current-duty-contact-username {
color: var(--accent);
text-decoration: none;
}
.current-duty-contact-link:hover,
.current-duty-contact-phone:hover,
.current-duty-contact-username:hover {
text-decoration: underline;
}
.current-duty-back-btn {
display: block;
width: 100%;
padding: 12px 16px;
margin-top: 8px;
font-size: 1rem;
font-weight: 500;
color: var(--bg);
background: var(--accent);
border: none;
border-radius: 8px;
cursor: pointer;
}
.current-duty-back-btn:hover {
opacity: 0.9;
}
.current-duty-back-btn:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.current-duty-loading {
text-align: center;
color: var(--muted);
}
.access-denied {
text-align: center;
padding: 24px 12px;
color: var(--muted);
}
.access-denied p {
margin: 0 0 8px 0;
}
.access-denied p:first-child {
color: var(--error);
font-weight: 600;
}
.access-denied .access-denied-detail {
margin-top: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.access-denied[hidden] {
display: none !important;
}

View File

@@ -1,33 +1,47 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="favicon.png" type="image/png">
<title>Календарь дежурств</title>
<link rel="stylesheet" href="style.css">
<title></title>
<link rel="stylesheet" href="css/base.css">
<link rel="stylesheet" href="css/calendar.css">
<link rel="stylesheet" href="css/day-detail.css">
<link rel="stylesheet" href="css/hints.css">
<link rel="stylesheet" href="css/markers.css">
<link rel="stylesheet" href="css/duty-list.css">
<link rel="stylesheet" href="css/states.css">
</head>
<body>
<div class="container">
<div class="calendar-sticky" id="calendarSticky">
<header class="header">
<button type="button" class="nav" id="prevMonth" aria-label="Предыдущий месяц"></button>
<button type="button" class="nav" id="prevMonth" aria-label=""></button>
<h1 class="title" id="monthTitle"></h1>
<button type="button" class="nav" id="nextMonth" aria-label="Следующий месяц"></button>
<button type="button" class="nav" id="nextMonth" aria-label=""></button>
</header>
<div class="weekdays">
<span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span>
<span></span><span></span><span></span><span></span><span></span><span></span><span></span>
</div>
<div class="calendar" id="calendar"></div>
</div>
<div class="duty-list" id="dutyList"></div>
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text">Загрузка…</span></div>
<div class="loading" id="loading"><span class="loading__spinner" aria-hidden="true"></span><span class="loading__text"></span></div>
<div class="error" id="error" hidden></div>
<div class="access-denied" id="accessDenied" hidden>
<p>Доступ запрещён.</p>
</div>
<div class="access-denied" id="accessDenied" hidden></div>
<div id="currentDutyView" class="current-duty-view hidden"></div>
</div>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script type="module" src="js/main.js"></script>
<script type="importmap">
{
"scopes": {
"./js/": {
"./js/i18n.js": "./js/i18n.js?v=1"
}
}
}
</script>
<script type="module" src="js/main.js?v=4"></script>
</body>
</html>

View File

@@ -9,16 +9,31 @@ import { t } from "./i18n.js";
/**
* Build fetch options with init data header, Accept-Language and timeout abort.
* Optional external signal (e.g. from loadMonth) aborts this request when triggered.
* @param {string} initData - Telegram init data
* @returns {{ headers: object, signal: AbortSignal, timeoutId: number }}
* @param {AbortSignal} [externalSignal] - when aborted, cancels this request
* @returns {{ headers: object, signal: AbortSignal, cleanup: () => void }}
*/
export function buildFetchOptions(initData) {
export function buildFetchOptions(initData, externalSignal) {
const headers = {};
if (initData) headers["X-Telegram-Init-Data"] = initData;
headers["Accept-Language"] = state.lang || "ru";
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
return { headers, signal: controller.signal, timeoutId };
const onAbort = () => controller.abort();
const cleanup = () => {
clearTimeout(timeoutId);
if (externalSignal) externalSignal.removeEventListener("abort", onAbort);
};
if (externalSignal) {
if (externalSignal.aborted) {
cleanup();
controller.abort();
} else {
externalSignal.addEventListener("abort", onAbort);
}
}
return { headers, signal: controller.signal, cleanup };
}
/**
@@ -26,70 +41,69 @@ export function buildFetchOptions(initData) {
* Caller checks res.ok, res.status, res.json().
* @param {string} path - e.g. "/api/duties"
* @param {{ from?: string, to?: string }} params - query params
* @param {{ signal?: AbortSignal }} [options] - optional abort signal for request cancellation
* @returns {Promise<Response>} - raw response
*/
export async function apiGet(path, params = {}) {
export async function apiGet(path, params = {}, options = {}) {
const base = window.location.origin;
const query = new URLSearchParams(params).toString();
const url = query ? `${base}${path}?${query}` : `${base}${path}`;
const initData = getInitData();
const opts = buildFetchOptions(initData);
const opts = buildFetchOptions(initData, options.signal);
try {
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
return res;
} finally {
clearTimeout(opts.timeoutId);
opts.cleanup();
}
}
/**
* Fetch duties for date range. Throws ACCESS_DENIED error on 403.
* AbortError is rethrown when the request is cancelled (e.g. stale loadMonth).
* @param {string} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>}
*/
export async function fetchDuties(from, to) {
try {
const res = await apiGet("/api/duties", { from, to });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
} catch (parseErr) {
/* ignore */
export async function fetchDuties(from, to, signal) {
const res = await apiGet("/api/duties", { from, to }, { signal });
if (res.status === 403) {
let detail = t(state.lang, "access_denied");
try {
const body = await res.json();
if (body && body.detail !== undefined) {
detail =
typeof body.detail === "string"
? body.detail
: (body.detail.msg || JSON.stringify(body.detail));
}
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
} catch (parseErr) {
/* ignore */
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
} catch (e) {
if (e.name === "AbortError") {
throw new Error(t(state.lang, "error_network"));
}
throw e;
const err = new Error("ACCESS_DENIED");
err.serverDetail = detail;
throw err;
}
if (!res.ok) throw new Error(t(state.lang, "error_load_failed"));
return res.json();
}
/**
* Fetch calendar events for range. Returns [] on non-200 or error. Does not throw for 403.
* Rethrows AbortError when the request is cancelled (e.g. stale loadMonth).
* @param {string} from - YYYY-MM-DD
* @param {string} to - YYYY-MM-DD
* @param {AbortSignal} [signal] - optional signal to cancel the request
* @returns {Promise<object[]>}
*/
export async function fetchCalendarEvents(from, to) {
export async function fetchCalendarEvents(from, to, signal) {
try {
const res = await apiGet("/api/calendar-events", { from, to });
const res = await apiGet("/api/calendar-events", { from, to }, { signal });
if (!res.ok) return [];
return res.json();
} catch (e) {
if (e.name === "AbortError") throw e;
return [];
}
}

212
webapp/js/api.test.js Normal file
View File

@@ -0,0 +1,212 @@
/**
* Unit tests for api: buildFetchOptions, fetchDuties (403 handling, AbortError).
*/
import { describe, it, expect, vi, beforeEach, afterEach, beforeAll } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { buildFetchOptions, fetchDuties, apiGet, fetchCalendarEvents } from "./api.js";
import { state } from "./dom.js";
describe("buildFetchOptions", () => {
beforeEach(() => {
state.lang = "ru";
});
it("sets X-Telegram-Init-Data when initData provided", () => {
const opts = buildFetchOptions("init-data-string");
expect(opts.headers["X-Telegram-Init-Data"]).toBe("init-data-string");
opts.cleanup();
});
it("omits X-Telegram-Init-Data when initData empty", () => {
const opts = buildFetchOptions("");
expect(opts.headers["X-Telegram-Init-Data"]).toBeUndefined();
opts.cleanup();
});
it("sets Accept-Language from state.lang", () => {
state.lang = "en";
const opts = buildFetchOptions("");
expect(opts.headers["Accept-Language"]).toBe("en");
opts.cleanup();
});
it("returns signal and cleanup function", () => {
const opts = buildFetchOptions("");
expect(opts.signal).toBeDefined();
expect(typeof opts.cleanup).toBe("function");
opts.cleanup();
});
it("cleanup clears timeout and removes external abort listener", () => {
const controller = new AbortController();
const opts = buildFetchOptions("", controller.signal);
opts.cleanup();
controller.abort();
});
});
describe("fetchDuties", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("test-init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ id: 1 }]),
});
const result = await fetchDuties("2025-02-01", "2025-02-28");
expect(result).toEqual([{ id: 1 }]);
});
it("throws ACCESS_DENIED on 403 with server detail from body", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Custom access denied" }),
});
await expect(fetchDuties("2025-02-01", "2025-02-28")).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Custom access denied",
});
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => {
return Promise.reject(abortError);
});
await expect(
fetchDuties("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("apiGet", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "en";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("builds URL with path and query params and returns response", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true, status: 200 });
});
await apiGet("/api/duties", { from: "2025-02-01", to: "2025-02-28" });
expect(capturedUrl).toContain("/api/duties");
expect(capturedUrl).toContain("from=2025-02-01");
expect(capturedUrl).toContain("to=2025-02-28");
});
it("omits query string when params empty", async () => {
let capturedUrl = "";
globalThis.fetch = vi.fn().mockImplementation((url) => {
capturedUrl = url;
return Promise.resolve({ ok: true });
});
await apiGet("/api/health", {});
expect(capturedUrl).toBe(window.location.origin + "/api/health");
});
it("passes X-Telegram-Init-Data and Accept-Language headers", async () => {
let capturedOpts = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedOpts = opts;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", { from: "2025-01-01", to: "2025-01-31" });
expect(capturedOpts?.headers["X-Telegram-Init-Data"]).toBe("init-data");
expect(capturedOpts?.headers["Accept-Language"]).toBe("en");
});
it("passes an abort signal to fetch when options.signal provided", async () => {
const controller = new AbortController();
let capturedSignal = null;
globalThis.fetch = vi.fn().mockImplementation((url, opts) => {
capturedSignal = opts.signal;
return Promise.resolve({ ok: true });
});
await apiGet("/api/duties", {}, { signal: controller.signal });
expect(capturedSignal).toBeDefined();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
});
describe("fetchCalendarEvents", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
mockGetInitData.mockReturnValue("init-data");
state.lang = "ru";
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns JSON array on 200", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
});
it("returns empty array on non-OK response", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("returns empty array on 403 (does not throw)", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
});
const result = await fetchCalendarEvents("2025-02-01", "2025-02-28");
expect(result).toEqual([]);
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
globalThis.fetch = vi.fn().mockImplementation(() => Promise.reject(abortError));
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});

155
webapp/js/auth.test.js Normal file
View File

@@ -0,0 +1,155 @@
/**
* Unit tests for auth: getTgWebAppDataFromHash, getInitData, isLocalhost.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
getTgWebAppDataFromHash,
getInitData,
isLocalhost,
hasTelegramHashButNoInitData,
} from "./auth.js";
describe("getTgWebAppDataFromHash", () => {
it("returns empty string when tgWebAppData= not present", () => {
expect(getTgWebAppDataFromHash("foo=bar")).toBe("");
});
it("returns value from tgWebAppData= to next &tgWebApp or end", () => {
expect(getTgWebAppDataFromHash("tgWebAppData=encoded%3Ddata")).toBe(
"encoded=data"
);
});
it("stops at &tgWebApp", () => {
const hash = "tgWebAppData=value&tgWebAppVersion=6";
expect(getTgWebAppDataFromHash(hash)).toBe("value");
});
it("decodes URI component", () => {
expect(getTgWebAppDataFromHash("tgWebAppData=hello%20world")).toBe(
"hello world"
);
});
});
describe("getInitData", () => {
const origLocation = window.location;
const origTelegram = window.Telegram;
afterEach(() => {
window.location = origLocation;
window.Telegram = origTelegram;
});
it("returns initData from Telegram.WebApp when set", () => {
window.Telegram = { WebApp: { initData: "sdk-init-data" } };
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(getInitData()).toBe("sdk-init-data");
});
it("returns data from hash tgWebAppData when SDK empty", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=hash%20data",
search: "",
};
expect(getInitData()).toBe("hash data");
});
it("returns empty string when no source", () => {
window.Telegram = { WebApp: { initData: "" } };
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(getInitData()).toBe("");
});
});
describe("isLocalhost", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns true for localhost", () => {
delete window.location;
window.location = { ...origLocation, hostname: "localhost" };
expect(isLocalhost()).toBe(true);
});
it("returns true for 127.0.0.1", () => {
delete window.location;
window.location = { ...origLocation, hostname: "127.0.0.1" };
expect(isLocalhost()).toBe(true);
});
it("returns true for empty hostname", () => {
delete window.location;
window.location = { ...origLocation, hostname: "" };
expect(isLocalhost()).toBe(true);
});
it("returns false for other hostnames", () => {
delete window.location;
window.location = { ...origLocation, hostname: "example.com" };
expect(isLocalhost()).toBe(false);
});
});
describe("hasTelegramHashButNoInitData", () => {
const origLocation = window.location;
afterEach(() => {
window.location = origLocation;
});
it("returns false when hash is empty", () => {
delete window.location;
window.location = { ...origLocation, hash: "", search: "" };
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns true when hash has tgWebAppVersion but no tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(true);
});
it("returns false when hash has both tgWebAppVersion and tgWebAppData", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppVersion=6&tgWebAppData=some%3Ddata",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has tgWebAppData in unencoded form (with & and =)", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#tgWebAppData=value&tgWebAppVersion=6",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
it("returns false when hash has no Telegram params", () => {
delete window.location;
window.location = {
...origLocation,
hash: "#other=param",
search: "",
};
expect(hasTelegramHashButNoInitData()).toBe(false);
});
});

View File

@@ -2,7 +2,7 @@
* Calendar grid and events-by-date mapping.
*/
import { calendarEl, monthTitleEl, state } from "./dom.js";
import { getCalendarEl, getMonthTitleEl, state } from "./dom.js";
import { monthName, t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import {
@@ -29,6 +29,9 @@ export function calendarEventsByDate(events) {
return byDate;
}
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
const MAX_DAYS_PER_DUTY = 366;
/**
* Group duties by local date (start_at/end_at are UTC).
* @param {object[]} duties - Duties with start_at, end_at
@@ -39,14 +42,17 @@ export function dutiesByDate(duties) {
duties.forEach((d) => {
const start = new Date(d.start_at);
const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end);
let t = new Date(start);
while (true) {
const key = localDateString(t);
let cursor = new Date(start);
let iterations = 0;
while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = [];
byDate[key].push(d);
if (key === endLocal) break;
t.setDate(t.getDate() + 1);
cursor.setDate(cursor.getDate() + 1);
iterations++;
}
});
return byDate;
@@ -65,6 +71,8 @@ export function renderCalendar(
dutiesByDateMap,
calendarEventsByDateMap
) {
const calendarEl = getCalendarEl();
const monthTitleEl = getMonthTitleEl();
if (!calendarEl || !monthTitleEl) return;
const first = firstDayOfMonth(new Date(year, month, 1));
const last = lastDayOfMonth(new Date(year, month, 1));
@@ -104,14 +112,8 @@ export function renderCalendar(
start_at: x.start_at,
end_at: x.end_at
}));
cell.setAttribute(
"data-day-duties",
JSON.stringify(dayPayload).replace(/"/g, "&quot;")
);
cell.setAttribute(
"data-day-events",
JSON.stringify(eventSummaries).replace(/"/g, "&quot;")
);
cell.setAttribute("data-day-duties", JSON.stringify(dayPayload));
cell.setAttribute("data-day-events", JSON.stringify(eventSummaries));
}
const ariaParts = [];

139
webapp/js/calendar.test.js Normal file
View File

@@ -0,0 +1,139 @@
/**
* Unit tests for calendar: dutiesByDate (including edge case end_at < start_at),
* calendarEventsByDate.
*/
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import { dutiesByDate, calendarEventsByDate, renderCalendar } from "./calendar.js";
import { state } from "./dom.js";
describe("dutiesByDate", () => {
it("groups duty by single local day", () => {
const duties = [
{
full_name: "Alice",
start_at: "2025-02-25T09:00:00Z",
end_at: "2025-02-25T18:00:00Z",
},
];
const byDate = dutiesByDate(duties);
expect(byDate["2025-02-25"]).toHaveLength(1);
expect(byDate["2025-02-25"][0].full_name).toBe("Alice");
});
it("spans duty across multiple days", () => {
const duties = [
{
full_name: "Bob",
start_at: "2025-02-25T00:00:00Z",
end_at: "2025-02-27T23:59:59Z",
},
];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeGreaterThanOrEqual(2);
keys.forEach((k) => expect(byDate[k]).toHaveLength(1));
expect(byDate[keys[0]][0].full_name).toBe("Bob");
});
it("skips duty when end_at < start_at (no infinite loop)", () => {
const duties = [
{
full_name: "Bad",
start_at: "2025-02-28T12:00:00Z",
end_at: "2025-02-25T08:00:00Z",
},
];
const byDate = dutiesByDate(duties);
expect(Object.keys(byDate)).toHaveLength(0);
});
it("does not iterate more than MAX_DAYS_PER_DUTY", () => {
const start = "2025-01-01T00:00:00Z";
const end = "2026-06-01T00:00:00Z";
const duties = [{ full_name: "Long", start_at: start, end_at: end }];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeLessThanOrEqual(367);
});
it("handles empty duties", () => {
expect(dutiesByDate([])).toEqual({});
});
});
describe("calendarEventsByDate", () => {
it("maps events to local date key by UTC date", () => {
const events = [
{ date: "2025-02-25", summary: "Holiday" },
{ date: "2025-02-25", summary: "Meeting" },
{ date: "2025-02-26", summary: "Other" },
];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]);
expect(byDate["2025-02-26"]).toEqual(["Other"]);
});
it("skips events without summary", () => {
const events = [{ date: "2025-02-25", summary: null }];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"] || []).toHaveLength(0);
});
it("handles null or undefined events", () => {
expect(calendarEventsByDate(null)).toEqual({});
expect(calendarEventsByDate(undefined)).toEqual({});
});
});
describe("renderCalendar", () => {
beforeEach(() => {
state.lang = "en";
});
it("renders 42 cells (6 weeks)", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = calendarEl?.querySelectorAll(".day") ?? [];
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
renderCalendar(2025, 0, {}, {});
const calendarEl = document.getElementById("calendar");
const cells = Array.from(calendarEl?.querySelectorAll(".day") ?? []);
const dates = cells.map((c) => c.getAttribute("data-date"));
expect(dates.every((d) => /^\d{4}-\d{2}-\d{2}$/.test(d ?? ""))).toBe(true);
});
it("adds today class to cell matching today", () => {
const today = new Date();
renderCalendar(today.getFullYear(), today.getMonth(), {}, {});
const calendarEl = document.getElementById("calendar");
const todayKey =
today.getFullYear() +
"-" +
String(today.getMonth() + 1).padStart(2, "0") +
"-" +
String(today.getDate()).padStart(2, "0");
const todayCell = calendarEl?.querySelector('.day.today[data-date="' + todayKey + '"]');
expect(todayCell).toBeTruthy();
});
it("sets month title from state.lang and given year/month", () => {
state.lang = "en";
renderCalendar(2025, 1, {}, {});
const titleEl = document.getElementById("monthTitle");
expect(titleEl?.textContent).toContain("2025");
expect(titleEl?.textContent).toContain("February");
});
});

150
webapp/js/contactHtml.js Normal file
View File

@@ -0,0 +1,150 @@
/**
* Shared HTML builder for contact links (phone, Telegram) used by day detail,
* current duty, and duty list.
*/
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
/** Phone icon SVG for block layout (inline, same style as dutyList). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Telegram / send icon SVG for block layout. */
const ICON_TELEGRAM =
'<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
/**
* Format Russian phone number for display: 79146522209 -> +7 914 652-22-09.
* Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX).
* Other lengths are returned as-is (digits only).
*
* @param {string} phone - Raw phone string (digits, optional leading 7/8)
* @returns {string} Formatted display string, e.g. "+7 914 652-22-09"
*/
export function formatPhoneDisplay(phone) {
if (phone == null || String(phone).trim() === "") return "";
const digits = String(phone).replace(/\D/g, "");
if (digits.length === 10) {
return "+7 " + digits.slice(0, 3) + " " + digits.slice(3, 6) + "-" + digits.slice(6, 8) + "-" + digits.slice(8);
}
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
const rest = digits.slice(1);
return "+7 " + rest.slice(0, 3) + " " + rest.slice(3, 6) + "-" + rest.slice(6, 8) + "-" + rest.slice(8);
}
return digits;
}
/**
* Build HTML for contact links (phone, Telegram username).
* Validates phone/username, builds tel: and t.me hrefs, wraps in spans/links.
*
* @param {'ru'|'en'} lang - UI language for labels (when showLabels is true)
* @param {string|null|undefined} phone - Phone number
* @param {string|null|undefined} username - Telegram username with or without leading @
* @param {object} options - Rendering options
* @param {string} options.classPrefix - CSS class prefix (e.g. "day-detail-contact", "duty-contact")
* @param {boolean} [options.showLabels=true] - Whether to show "Phone:" / "Telegram:" labels (ignored when layout is "block")
* @param {string} [options.separator=' '] - Separator between contact parts (e.g. " ", " · ")
* @param {'inline'|'block'} [options.layout='inline'] - "block" = full-width button blocks with SVG icons (for current duty card)
* @returns {string} HTML string or "" if no valid contact
*/
export function buildContactLinksHtml(lang, phone, username, options) {
const { classPrefix, showLabels = true, separator = " ", layout = "inline" } = options || {};
const parts = [];
if (phone && String(phone).trim()) {
const p = String(phone).trim();
const safeHref =
"tel:" +
p.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
const displayPhone = formatPhoneDisplay(p);
if (layout === "block") {
const blockClass = classPrefix + "-block " + classPrefix + "-block--phone";
parts.push(
'<a href="' +
safeHref +
'" class="' +
escapeHtml(blockClass) +
'">' +
ICON_PHONE +
"<span>" +
escapeHtml(displayPhone) +
"</span></a>"
);
} else {
const linkHtml =
'<a href="' +
safeHref +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-phone") +
'">' +
escapeHtml(displayPhone) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.phone");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
}
if (username && String(username).trim()) {
const u = String(username).trim().replace(/^@+/, "");
if (u) {
const display = "@" + u;
const href = "https://t.me/" + encodeURIComponent(u);
if (layout === "block") {
const blockClass = classPrefix + "-block " + classPrefix + "-block--telegram";
parts.push(
'<a href="' +
escapeHtml(href) +
'" class="' +
escapeHtml(blockClass) +
'" target="_blank" rel="noopener noreferrer">' +
ICON_TELEGRAM +
"<span>" +
escapeHtml(display) +
"</span></a>"
);
} else {
const linkHtml =
'<a href="' +
escapeHtml(href) +
'" class="' +
escapeHtml(classPrefix + "-link " + classPrefix + "-username") +
'" target="_blank" rel="noopener noreferrer">' +
escapeHtml(display) +
"</a>";
if (showLabels) {
const label = t(lang, "contact.telegram");
parts.push(
'<span class="' +
escapeHtml(classPrefix) +
'">' +
escapeHtml(label) +
": " +
linkHtml +
"</span>"
);
} else {
parts.push(linkHtml);
}
}
}
}
if (parts.length === 0) return "";
const rowClass = classPrefix + "-row" + (layout === "block" ? " " + classPrefix + "-row--blocks" : "");
const inner = layout === "block" ? parts.join("") : parts.join(separator);
return '<div class="' + escapeHtml(rowClass) + '">' + inner + "</div>";
}

View File

@@ -0,0 +1,176 @@
/**
* Unit tests for contactHtml (formatPhoneDisplay, buildContactLinksHtml).
*/
import { describe, it, expect } from "vitest";
import { formatPhoneDisplay, buildContactLinksHtml } from "./contactHtml.js";
describe("formatPhoneDisplay", () => {
it("formats 11-digit number starting with 7", () => {
expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09");
expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09");
});
it("formats 11-digit number starting with 8", () => {
expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09");
});
it("formats 10-digit number as Russian", () => {
expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09");
});
it("returns empty string for null or empty", () => {
expect(formatPhoneDisplay(null)).toBe("");
expect(formatPhoneDisplay("")).toBe("");
expect(formatPhoneDisplay(" ")).toBe("");
});
it("strips non-digits before formatting", () => {
expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09");
});
it("returns digits as-is for non-10/11 length", () => {
expect(formatPhoneDisplay("123")).toBe("123");
expect(formatPhoneDisplay("12345678901")).toBe("12345678901");
});
});
describe("buildContactLinksHtml", () => {
const baseOptions = { classPrefix: "test-contact", showLabels: true, separator: " " };
it("returns empty string when phone and username are missing", () => {
expect(buildContactLinksHtml("en", null, null, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", undefined, undefined, baseOptions)).toBe("");
expect(buildContactLinksHtml("en", "", "", baseOptions)).toBe("");
expect(buildContactLinksHtml("en", " ", " ", baseOptions)).toBe("");
});
it("renders phone only with label and tel: link", () => {
const html = buildContactLinksHtml("en", "+79991234567", null, baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("Phone");
expect(html).not.toContain("t.me");
});
it("displays phone formatted for Russian numbers", () => {
const html = buildContactLinksHtml("en", "79146522209", null, baseOptions);
expect(html).toContain("+7 914 652-22-09");
expect(html).toContain('href="tel:79146522209"');
});
it("renders username only with label and t.me link", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("@alice_dev");
expect(html).toContain("Telegram");
expect(html).not.toContain("tel:");
});
it("renders both phone and username with labels", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", baseOptions);
expect(html).toContain("test-contact-row");
expect(html).toContain("tel:");
expect(html).toContain("+79001112233");
expect(html).toContain("+7 900 111-22-33");
expect(html).toContain("t.me");
expect(html).toContain("@bob");
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
});
it("strips leading @ from username and displays with @", () => {
const html = buildContactLinksHtml("en", null, "@alice", baseOptions);
expect(html).toContain("https://t.me/alice");
expect(html).toContain("@alice");
expect(html).not.toContain("@@");
});
it("handles multiple leading @ in username", () => {
const html = buildContactLinksHtml("en", null, "@@@user", baseOptions);
expect(html).toContain("https://t.me/user");
expect(html).toContain("@user");
});
it("escapes special characters in phone href; display uses formatted digits only", () => {
const html = buildContactLinksHtml("en", '+7 999 "1" <2>', null, baseOptions);
expect(html).toContain("&quot;");
expect(html).toContain("&lt;");
expect(html).toContain("tel:");
expect(html).toContain("799912");
expect(html).not.toContain("<2>");
expect(html).not.toContain('"1"');
});
it("uses custom separator when showLabels is false", () => {
const html = buildContactLinksHtml("en", "+7999", "u1", {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
expect(html).toContain(" · ");
expect(html).not.toContain("Phone");
expect(html).not.toContain("Telegram");
expect(html).toContain("duty-contact-row");
expect(html).toContain("duty-contact-link");
});
it("uses Russian labels when lang is ru", () => {
const html = buildContactLinksHtml("ru", "+7999", null, baseOptions);
expect(html).toContain("Телефон");
const htmlTg = buildContactLinksHtml("ru", null, "u", baseOptions);
expect(htmlTg).toContain("Telegram");
});
it("uses default showLabels true and separator space when options omit them", () => {
const html = buildContactLinksHtml("en", "+7999", "u", {
classPrefix: "minimal",
});
expect(html).toContain("Phone");
expect(html).toContain("Telegram");
expect(html).toContain("minimal-row");
expect(html).not.toContain(" · ");
});
describe("layout: block", () => {
it("renders phone as block with icon and formatted number", () => {
const html = buildContactLinksHtml("en", "79146522209", null, {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-row--blocks");
expect(html).toContain("current-duty-contact-block");
expect(html).toContain("current-duty-contact-block--phone");
expect(html).toContain("+7 914 652-22-09");
expect(html).toContain("tel:");
expect(html).toContain("<svg");
expect(html).not.toContain("Phone");
});
it("renders telegram as block with icon and @username", () => {
const html = buildContactLinksHtml("en", null, "alice_dev", {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-block--telegram");
expect(html).toContain("https://t.me/");
expect(html).toContain("@alice_dev");
expect(html).toContain("<svg");
expect(html).not.toContain("Telegram");
});
it("renders both phone and telegram as stacked blocks", () => {
const html = buildContactLinksHtml("en", "+79001112233", "bob", {
classPrefix: "current-duty-contact",
layout: "block",
});
expect(html).toContain("current-duty-contact-block--phone");
expect(html).toContain("current-duty-contact-block--telegram");
expect(html).toContain("+7 900 111-22-33");
expect(html).toContain("@bob");
});
});
});

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

@@ -0,0 +1,234 @@
/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one (start <= now < end), shows name, shift, contacts.
*/
import { getCurrentDutyViewEl, state, getLoadingEl } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { fetchDuties } from "./api.js";
import {
localDateString,
dateKeyToDDMM,
formatHHMM
} from "./dateUtils.js";
/** Empty calendar icon for "no duty" state (outline, stroke). */
const ICON_NO_DUTY =
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
/** @type {(() => void)|null} Callback when user taps "Back to calendar". */
let onBackCallback = null;
/** @type {(() => void)|null} Handler registered with Telegram BackButton.onClick. */
let backButtonHandler = null;
/**
* Compute remaining time until end of shift. Call only when now < end (active duty).
* @param {string|Date} endAt - ISO end time of the shift
* @returns {{ hours: number, minutes: number }}
*/
export function getRemainingTime(endAt) {
const end = new Date(endAt).getTime();
const now = Date.now();
const ms = Math.max(0, end - now);
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
return { hours, minutes };
}
/**
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
* @param {object[]} duties - List of duties with start_at, end_at, event_type
* @returns {object|null}
*/
export function findCurrentDuty(duties) {
const now = Date.now();
const dutyType = (duties || []).filter((d) => d.event_type === "duty");
const candidates = dutyType.length ? dutyType : duties || [];
for (const d of candidates) {
const start = new Date(d.start_at).getTime();
const end = new Date(d.end_at).getTime();
if (start <= now && now < end) return d;
}
return null;
}
/**
* Render the current duty view content (card with duty or no-duty message).
* @param {object|null} duty - Active duty or null
* @param {string} lang
* @returns {string}
*/
export function renderCurrentDutyContent(duty, lang) {
const backLabel = t(lang, "current_duty.back");
const title = t(lang, "current_duty.title");
if (!duty) {
const noDuty = t(lang, "current_duty.no_duty");
return (
'<div class="current-duty-card current-duty-card--no-duty">' +
'<h2 class="current-duty-title">' +
escapeHtml(title) +
"</h2>" +
'<div class="current-duty-no-duty-wrap">' +
'<span class="current-duty-no-duty-icon">' +
ICON_NO_DUTY +
"</span>" +
'<p class="current-duty-no-duty">' +
escapeHtml(noDuty) +
"</p>" +
"</div>" +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(backLabel) +
"</button>" +
"</div>"
);
}
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
const endTime = formatHHMM(duty.end_at);
const shiftStr =
startDDMM +
" " +
startTime +
" — " +
endDDMM +
" " +
endTime;
const shiftLabel = t(lang, "current_duty.shift");
const { hours: remHours, minutes: remMinutes } = getRemainingTime(duty.end_at);
const remainingStr = t(lang, "current_duty.remaining", {
hours: String(remHours),
minutes: String(remMinutes)
});
const contactHtml = buildContactLinksHtml(lang, duty.phone, duty.username, {
classPrefix: "current-duty-contact",
showLabels: true,
separator: " ",
layout: "block"
});
return (
'<div class="current-duty-card">' +
'<h2 class="current-duty-title">' +
'<span class="current-duty-live-dot"></span> ' +
escapeHtml(title) +
"</h2>" +
'<p class="current-duty-name">' +
escapeHtml(duty.full_name) +
"</p>" +
'<div class="current-duty-shift">' +
escapeHtml(shiftLabel) +
": " +
escapeHtml(shiftStr) +
"</div>" +
'<div class="current-duty-remaining">' +
escapeHtml(remainingStr) +
"</div>" +
contactHtml +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(backLabel) +
"</button>" +
"</div>"
);
}
/**
* Show the current duty view: fetch today's duties, render card or no-duty, show back button.
* Hides calendar/duty list and shows #currentDutyView. Optionally shows Telegram BackButton.
* @param {() => void} onBack - Callback when user taps "Back to calendar"
*/
export async function showCurrentDutyView(onBack) {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (!currentDutyViewEl) return;
onBackCallback = onBack;
currentDutyViewEl.classList.remove("hidden");
if (container) container.setAttribute("data-view", "currentDuty");
if (calendarSticky) calendarSticky.hidden = true;
if (dutyList) dutyList.hidden = true;
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.add("hidden");
const lang = state.lang;
currentDutyViewEl.innerHTML =
'<div class="current-duty-loading">' +
escapeHtml(t(lang, "loading")) +
"</div>";
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
window.Telegram.WebApp.BackButton.show();
const handler = () => {
if (onBackCallback) onBackCallback();
};
backButtonHandler = handler;
window.Telegram.WebApp.BackButton.onClick(handler);
}
const today = new Date();
const from = localDateString(today);
const to = from;
try {
const duties = await fetchDuties(from, to);
const duty = findCurrentDuty(duties);
currentDutyViewEl.innerHTML = renderCurrentDutyContent(duty, lang);
} catch (e) {
currentDutyViewEl.innerHTML =
'<div class="current-duty-card">' +
'<p class="current-duty-error">' +
escapeHtml(e.message || t(lang, "error_generic")) +
"</p>" +
'<button type="button" class="current-duty-back-btn" data-action="back">' +
escapeHtml(t(lang, "current_duty.back")) +
"</button>" +
"</div>";
}
currentDutyViewEl.addEventListener("click", handleCurrentDutyClick);
}
/**
* Delegate click for back button.
* @param {MouseEvent} e
*/
function handleCurrentDutyClick(e) {
const btn = e.target && e.target.closest("[data-action='back']");
if (!btn) return;
if (onBackCallback) onBackCallback();
}
/**
* Hide the current duty view and show calendar/duty list again.
* Hides Telegram BackButton and calls loadMonth so calendar is populated.
*/
export function hideCurrentDutyView() {
const currentDutyViewEl = getCurrentDutyViewEl();
const container = currentDutyViewEl && currentDutyViewEl.closest(".container");
const calendarSticky = document.getElementById("calendarSticky");
const dutyList = document.getElementById("dutyList");
if (window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.BackButton) {
if (backButtonHandler) {
window.Telegram.WebApp.BackButton.offClick(backButtonHandler);
}
window.Telegram.WebApp.BackButton.hide();
}
if (currentDutyViewEl) {
currentDutyViewEl.removeEventListener("click", handleCurrentDutyClick);
currentDutyViewEl.classList.add("hidden");
currentDutyViewEl.innerHTML = "";
}
onBackCallback = null;
backButtonHandler = null;
if (container) container.removeAttribute("data-view");
if (calendarSticky) calendarSticky.hidden = false;
if (dutyList) dutyList.hidden = false;
}

View File

@@ -0,0 +1,156 @@
/**
* Unit tests for currentDuty (findCurrentDuty, renderCurrentDutyContent, showCurrentDutyView).
*/
import { describe, it, expect, beforeAll, vi } from "vitest";
vi.mock("./api.js", () => ({
fetchDuties: vi.fn().mockResolvedValue([])
}));
import {
findCurrentDuty,
getRemainingTime,
renderCurrentDutyContent
} from "./currentDuty.js";
describe("currentDuty", () => {
beforeAll(() => {
document.body.innerHTML =
'<div id="loading"></div>' +
'<div class="container">' +
'<div id="calendarSticky"></div>' +
'<div id="dutyList"></div>' +
'<div id="currentDutyView" class="current-duty-view hidden"></div>' +
"</div>";
});
describe("getRemainingTime", () => {
it("returns hours and minutes until end from now", () => {
const endAt = "2025-03-02T17:30:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(5);
expect(minutes).toBe(30);
});
it("returns 0 when end is in the past", () => {
const endAt = "2025-03-02T09:00:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(0);
expect(minutes).toBe(0);
});
});
describe("findCurrentDuty", () => {
it("returns duty when now is between start_at and end_at", () => {
const now = new Date();
const start = new Date(now);
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1, 0, 0, 0);
const duties = [
{
event_type: "duty",
full_name: "Иванов",
start_at: start.toISOString(),
end_at: end.toISOString()
}
];
const duty = findCurrentDuty(duties);
expect(duty).not.toBeNull();
expect(duty.full_name).toBe("Иванов");
});
it("returns null when no duty overlaps current time", () => {
const duties = [
{
event_type: "duty",
full_name: "Past",
start_at: "2020-01-01T09:00:00Z",
end_at: "2020-01-01T17:00:00Z"
},
{
event_type: "duty",
full_name: "Future",
start_at: "2030-01-01T09:00:00Z",
end_at: "2030-01-01T17:00:00Z"
}
];
expect(findCurrentDuty(duties)).toBeNull();
});
});
describe("renderCurrentDutyContent", () => {
it("renders no-duty message and back button when duty is null", () => {
const html = renderCurrentDutyContent(null, "en");
expect(html).toContain("current-duty-card");
expect(html).toContain("current-duty-card--no-duty");
expect(html).toContain("Current Duty");
expect(html).toContain("current-duty-no-duty-wrap");
expect(html).toContain("current-duty-no-duty-icon");
expect(html).toContain("current-duty-no-duty");
expect(html).toContain("No one is on duty right now");
expect(html).toContain("Back to calendar");
expect(html).toContain('data-action="back"');
expect(html).not.toContain("current-duty-live-dot");
});
it("renders duty card with name, shift, remaining time, and back button when duty has no contacts", () => {
const duty = {
event_type: "duty",
full_name: "Иванов Иван",
start_at: "2025-03-02T06:00:00.000Z",
end_at: "2025-03-03T06:00:00.000Z"
};
const html = renderCurrentDutyContent(duty, "ru");
expect(html).toContain("current-duty-live-dot");
expect(html).toContain("Текущее дежурство");
expect(html).toContain("Иванов Иван");
expect(html).toContain("Смена");
expect(html).toContain("current-duty-remaining");
expect(html).toMatch(/Осталось:\s*\d+ч\s*\d+мин/);
expect(html).toContain("Назад к календарю");
expect(html).toContain('data-action="back"');
});
it("renders duty card with phone and Telegram links when present", () => {
const duty = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-02T09:00:00",
end_at: "2025-03-02T17:00:00",
phone: "+7 900 123-45-67",
username: "alice_dev"
};
const html = renderCurrentDutyContent(duty, "en");
expect(html).toContain("Alice");
expect(html).toContain("current-duty-remaining");
expect(html).toMatch(/Remaining:\s*\d+h\s*\d+min/);
expect(html).toContain("current-duty-contact-row");
expect(html).toContain("current-duty-contact-row--blocks");
expect(html).toContain("current-duty-contact-block");
expect(html).toContain('href="tel:');
expect(html).toContain("+7 900 123-45-67");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
expect(html).toContain("Back to calendar");
});
});
describe("showCurrentDutyView", () => {
it("hides the global loading element when called", async () => {
vi.resetModules();
const { showCurrentDutyView } = await import("./currentDuty.js");
await showCurrentDutyView(() => {});
const loading = document.getElementById("loading");
expect(loading).not.toBeNull();
expect(loading.classList.contains("hidden")).toBe(true);
});
});
});

View File

@@ -84,16 +84,6 @@ export function dateKeyToDDMM(key) {
return key.slice(8, 10) + "." + key.slice(5, 7);
}
/**
* Format ISO date as HH:MM in local time.
* @param {string} isoStr - ISO date string
* @returns {string} HH:MM
*/
export function formatTimeLocal(isoStr) {
const d = new Date(isoStr);
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
}
/**
* Format ISO string as HH:MM (local).
* @param {string} isoStr - ISO date string

230
webapp/js/dateUtils.test.js Normal file
View File

@@ -0,0 +1,230 @@
/**
* Unit tests for dateUtils: localDateString, dutyOverlapsLocalDay,
* dutyOverlapsLocalRange, getMonday, formatHHMM.
*/
import { describe, it, expect } from "vitest";
import {
localDateString,
dutyOverlapsLocalDay,
dutyOverlapsLocalRange,
getMonday,
formatHHMM,
firstDayOfMonth,
lastDayOfMonth,
formatDateKey,
dateKeyToDDMM,
} from "./dateUtils.js";
describe("localDateString", () => {
it("formats date as YYYY-MM-DD", () => {
const d = new Date(2025, 0, 15);
expect(localDateString(d)).toBe("2025-01-15");
});
it("pads month and day with zero", () => {
expect(localDateString(new Date(2025, 0, 5))).toBe("2025-01-05");
expect(localDateString(new Date(2025, 8, 9))).toBe("2025-09-09");
});
it("handles December and year boundary", () => {
expect(localDateString(new Date(2024, 11, 31))).toBe("2024-12-31");
});
});
describe("dutyOverlapsLocalDay", () => {
it("returns true when duty spans the whole day", () => {
const d = {
start_at: "2025-02-25T00:00:00Z",
end_at: "2025-02-25T23:59:59Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns true when duty overlaps part of the day", () => {
const d = {
start_at: "2025-02-25T09:00:00Z",
end_at: "2025-02-25T14:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns true when duty continues from previous day", () => {
const d = {
start_at: "2025-02-24T22:00:00Z",
end_at: "2025-02-25T06:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
});
it("returns false when duty ends before the day", () => {
const d = {
start_at: "2025-02-24T09:00:00Z",
end_at: "2025-02-24T18:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
});
it("returns false when duty starts after the day", () => {
const d = {
start_at: "2025-02-26T09:00:00Z",
end_at: "2025-02-26T18:00:00Z",
};
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
});
});
describe("dutyOverlapsLocalRange", () => {
it("returns true when duty overlaps the range", () => {
const d = {
start_at: "2025-02-24T12:00:00Z",
end_at: "2025-02-26T12:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
});
it("returns true when duty is entirely inside the range", () => {
const d = {
start_at: "2025-02-26T09:00:00Z",
end_at: "2025-02-26T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
});
it("returns false when duty ends before range start", () => {
const d = {
start_at: "2025-02-20T09:00:00Z",
end_at: "2025-02-22T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
});
it("returns false when duty starts after range end", () => {
const d = {
start_at: "2025-03-01T09:00:00Z",
end_at: "2025-03-01T18:00:00Z",
};
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
});
});
describe("getMonday", () => {
it("returns same day when date is Monday", () => {
const monday = new Date(2025, 0, 6); // 6 Jan 2025 is Monday
const result = getMonday(monday);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(6);
expect(result.getDay()).toBe(1);
});
it("returns previous Monday for Wednesday", () => {
const wed = new Date(2025, 0, 8);
const result = getMonday(wed);
expect(result.getDay()).toBe(1);
expect(result.getDate()).toBe(6);
});
it("returns Monday of same week for Sunday", () => {
const sun = new Date(2025, 0, 12);
const result = getMonday(sun);
expect(result.getDay()).toBe(1);
expect(result.getDate()).toBe(6);
});
});
describe("formatHHMM", () => {
it("formats ISO string as HH:MM in local time", () => {
const s = "2025-02-25T14:30:00Z";
const result = formatHHMM(s);
expect(result).toMatch(/^\d{2}:\d{2}$/);
const d = new Date(s);
const expected = (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes();
expect(result).toBe(expected);
});
it("returns empty string for null", () => {
expect(formatHHMM(null)).toBe("");
});
it("returns empty string for undefined", () => {
expect(formatHHMM(undefined)).toBe("");
});
it("returns empty string for empty string", () => {
expect(formatHHMM("")).toBe("");
});
it("pads hours and minutes with zero", () => {
const s = "2025-02-25T09:05:00Z";
const result = formatHHMM(s);
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe("firstDayOfMonth", () => {
it("returns first day of month", () => {
const d = new Date(2025, 5, 15);
const result = firstDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(5);
expect(result.getDate()).toBe(1);
});
it("handles January", () => {
const d = new Date(2025, 0, 31);
const result = firstDayOfMonth(d);
expect(result.getDate()).toBe(1);
expect(result.getMonth()).toBe(0);
});
});
describe("lastDayOfMonth", () => {
it("returns last day of month", () => {
const d = new Date(2025, 0, 15);
const result = lastDayOfMonth(d);
expect(result.getFullYear()).toBe(2025);
expect(result.getMonth()).toBe(0);
expect(result.getDate()).toBe(31);
});
it("returns 28 for non-leap February", () => {
const d = new Date(2023, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(28);
expect(result.getMonth()).toBe(1);
});
it("returns 29 for leap February", () => {
const d = new Date(2024, 1, 1);
const result = lastDayOfMonth(d);
expect(result.getDate()).toBe(29);
});
});
describe("formatDateKey", () => {
it("formats ISO date string as DD.MM (local time)", () => {
const result = formatDateKey("2025-02-25T00:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
const [day, month] = result.split(".");
expect(Number(day)).toBeGreaterThanOrEqual(1);
expect(Number(day)).toBeLessThanOrEqual(31);
expect(Number(month)).toBeGreaterThanOrEqual(1);
expect(Number(month)).toBeLessThanOrEqual(12);
});
it("returns DD.MM format with zero-padding", () => {
const result = formatDateKey("2025-01-05T12:00:00Z");
expect(result).toMatch(/^\d{2}\.\d{2}$/);
});
});
describe("dateKeyToDDMM", () => {
it("converts YYYY-MM-DD to DD.MM", () => {
expect(dateKeyToDDMM("2025-02-25")).toBe("25.02");
});
it("handles single-digit day and month", () => {
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
});
});

View File

@@ -2,9 +2,10 @@
* Day detail panel: popover (desktop) or bottom sheet (mobile) on calendar cell tap.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import { localDateString, dateKeyToDDMM } from "./dateUtils.js";
import { getDutyMarkerRows } from "./hints.js";
@@ -28,8 +29,7 @@ let sheetScrollY = 0;
function parseDataAttr(raw) {
if (!raw) return [];
try {
const s = raw.replace(/&quot;/g, '"');
const parsed = JSON.parse(s);
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
@@ -52,7 +52,9 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
? t(lang, "duty.today") + ", " + ddmm
: ddmm;
const dutyList = (duties || []).filter((d) => d.event_type === "duty");
const dutyList = (duties || [])
.filter((d) => d.event_type === "duty")
.sort((a, b) => new Date(a.start_at || 0) - new Date(b.start_at || 0));
const unavailableList = (duties || []).filter((d) => d.event_type === "unavailable");
const vacationList = (duties || []).filter((d) => d.event_type === "vacation");
const summaries = eventSummaries || [];
@@ -71,7 +73,12 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
);
const rows = hasTimes
? getDutyMarkerRows(dutyList, dateKey, nbsp, fromLabel, toLabel)
: dutyList.map((it) => ({ timePrefix: "", fullName: it.full_name || "" }));
: dutyList.map((it) => ({
timePrefix: "",
fullName: it.full_name || "",
phone: it.phone,
username: it.username
}));
html +=
'<section class="day-detail-section day-detail-section--duty">' +
@@ -79,12 +86,21 @@ export function buildDayDetailContent(dateKey, duties, eventSummaries) {
escapeHtml(t(lang, "event_type.duty")) +
"</h3><ul class=" +
'"day-detail-list">';
rows.forEach((r) => {
rows.forEach((r, i) => {
const duty = hasTimes ? dutyList[i] : null;
const phone = r.phone != null ? r.phone : (duty && duty.phone);
const username = r.username != null ? r.username : (duty && duty.username);
const timeHtml = r.timePrefix ? escapeHtml(r.timePrefix) + " — " : "";
const contactHtml = buildContactLinksHtml(lang, phone, username, {
classPrefix: "day-detail-contact",
showLabels: true,
separator: " "
});
html +=
"<li>" +
(timeHtml ? '<span class="day-detail-time">' + timeHtml + "</span>" : "") +
escapeHtml(r.fullName) +
(contactHtml ? contactHtml : "") +
"</li>";
});
html += "</ul></section>";
@@ -151,6 +167,7 @@ function positionPopover(panel, cellRect) {
const panelRect = panel.getBoundingClientRect();
let left = cellRect.left + cellRect.width / 2 - panelRect.width / 2;
let top = cellRect.bottom + 8;
/* day-detail-panel--below: panel is positioned above the cell (not enough space below). Used for optional styling (e.g. arrow). */
if (top + panelRect.height > vh - margin) {
top = cellRect.top - panelRect.height - 8;
panel.classList.add("day-detail-panel--below");
@@ -208,6 +225,7 @@ function showAsPopover(cellRect) {
const target = e.target instanceof Node ? e.target : null;
if (!target || !panelEl) return;
if (panelEl.contains(target)) return;
const calendarEl = getCalendarEl();
if (target instanceof HTMLElement && calendarEl && calendarEl.contains(target) && target.closest(".day")) return;
hideDayDetail();
};
@@ -342,6 +360,7 @@ function ensurePanelInDom() {
* Bind delegated click/keydown on calendar for .day cells.
*/
export function initDayDetail() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl.addEventListener("click", (e) => {
const cell = /** @type {HTMLElement} */ (e.target instanceof HTMLElement ? e.target.closest(".day") : null);

View File

@@ -0,0 +1,62 @@
/**
* Unit tests for buildDayDetailContent.
* Verifies dutyList is sorted by start_at before display.
*/
import { describe, it, expect, beforeAll } from "vitest";
import { buildDayDetailContent } from "./dayDetail.js";
describe("buildDayDetailContent", () => {
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>';
});
it("sorts duty list by start_at when input order is wrong", () => {
const dateKey = "2025-02-25";
const duties = [
{
event_type: "duty",
full_name: "Петров",
start_at: "2025-02-25T14:00:00",
end_at: "2025-02-25T18:00:00",
},
{
event_type: "duty",
full_name: "Иванов",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T14:00:00",
},
];
const html = buildDayDetailContent(dateKey, duties, []);
expect(html).toContain("Иванов");
expect(html).toContain("Петров");
const ivanovPos = html.indexOf("Иванов");
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");
});
});

View File

@@ -1,36 +1,62 @@
/**
* DOM references and shared application state.
* Element refs are resolved lazily via getters so modules can be imported before DOM is ready.
*/
/** @type {HTMLDivElement|null} */
export const calendarEl = document.getElementById("calendar");
/** @returns {HTMLDivElement|null} */
export function getCalendarEl() {
return document.getElementById("calendar");
}
/** @type {HTMLElement|null} */
export const monthTitleEl = document.getElementById("monthTitle");
/** @returns {HTMLElement|null} */
export function getMonthTitleEl() {
return document.getElementById("monthTitle");
}
/** @type {HTMLDivElement|null} */
export const dutyListEl = document.getElementById("dutyList");
/** @returns {HTMLDivElement|null} */
export function getDutyListEl() {
return document.getElementById("dutyList");
}
/** @type {HTMLElement|null} */
export const loadingEl = document.getElementById("loading");
/** @returns {HTMLElement|null} */
export function getLoadingEl() {
return document.getElementById("loading");
}
/** @type {HTMLElement|null} */
export const errorEl = document.getElementById("error");
/** @returns {HTMLElement|null} */
export function getErrorEl() {
return document.getElementById("error");
}
/** @type {HTMLElement|null} */
export const accessDeniedEl = document.getElementById("accessDenied");
/** @returns {HTMLElement|null} */
export function getAccessDeniedEl() {
return document.getElementById("accessDenied");
}
/** @type {HTMLElement|null} */
export const headerEl = document.querySelector(".header");
/** @returns {HTMLElement|null} */
export function getHeaderEl() {
return document.querySelector(".header");
}
/** @type {HTMLElement|null} */
export const weekdaysEl = document.querySelector(".weekdays");
/** @returns {HTMLElement|null} */
export function getWeekdaysEl() {
return document.querySelector(".weekdays");
}
/** @type {HTMLButtonElement|null} */
export const prevBtn = document.getElementById("prevMonth");
/** @returns {HTMLButtonElement|null} */
export function getPrevBtn() {
return document.getElementById("prevMonth");
}
/** @type {HTMLButtonElement|null} */
export const nextBtn = document.getElementById("nextMonth");
/** @returns {HTMLButtonElement|null} */
export function getNextBtn() {
return document.getElementById("nextMonth");
}
/** @returns {HTMLDivElement|null} */
export function getCurrentDutyViewEl() {
return document.getElementById("currentDutyView");
}
/** Currently viewed month (mutable). */
export const state = {
@@ -41,5 +67,13 @@ export const state = {
/** @type {ReturnType<typeof setInterval>|null} */
todayRefreshInterval: null,
/** @type {'ru'|'en'} */
lang: "ru"
lang: "ru",
/** One-time bind flag for sticky scroll shadow listener. */
stickyScrollBound: false,
/** One-time bind flag for calendar (info button) hint document listeners. */
calendarHintBound: false,
/** One-time bind flag for duty marker hint document listeners. */
dutyMarkerHintBound: false,
/** Whether initData retry after ACCESS_DENIED has been attempted. */
initDataRetried: false
};

View File

@@ -2,20 +2,31 @@
* Duty list (timeline) rendering.
*/
import { dutyListEl, state } from "./dom.js";
import { getDutyListEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { buildContactLinksHtml } from "./contactHtml.js";
import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
dateKeyToDDMM,
formatTimeLocal,
formatHHMM,
formatDateKey
} from "./dateUtils.js";
/** Phone icon SVG for flip button (show contacts). */
const ICON_PHONE =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
/** Back/arrow icon SVG for flip button (back to card). */
const ICON_BACK =
'<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>';
/**
* Build HTML for one timeline duty card: one-day "DD.MM, HH:MM HH:MM" or multi-day.
* When duty has phone or username, wraps in a flip-card (front: info + button; back: contacts).
* Otherwise returns a plain card without flip wrapper.
* @param {object} d - Duty
* @param {boolean} isCurrent - Whether this is "current" duty
* @returns {string}
@@ -25,8 +36,8 @@ export function dutyTimelineCardHtml(d, isCurrent) {
const endLocal = localDateString(new Date(d.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatTimeLocal(d.start_at);
const endTime = formatTimeLocal(d.end_at);
const startTime = formatHHMM(d.start_at);
const endTime = formatHHMM(d.end_at);
let timeStr;
if (startLocal === endLocal) {
timeStr = startDDMM + ", " + startTime + " " + endTime;
@@ -38,15 +49,66 @@ export function dutyTimelineCardHtml(d, isCurrent) {
? t(lang, "duty.now_on_duty")
: (t(lang, "event_type." + (d.event_type || "duty")));
const extraClass = isCurrent ? " duty-item--current" : "";
const contactHtml = buildContactLinksHtml(lang, d.phone, d.username, {
classPrefix: "duty-contact",
showLabels: false,
separator: " · "
});
const hasContacts = Boolean(
(d.phone && String(d.phone).trim()) ||
(d.username && String(d.username).trim())
);
if (!hasContacts) {
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
extraClass +
'"><span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
"</div></div>"
);
}
const showLabel = t(lang, "contact.show");
const backLabel = t(lang, "contact.back");
return (
'<div class="duty-item duty-item--duty duty-timeline-card' +
'<div class="duty-flip-card' +
extraClass +
'"><span class="duty-item-type">' +
'" data-flipped="false">' +
'<div class="duty-flip-inner">' +
'<div class="duty-flip-front duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="duty-item-type">' +
escapeHtml(typeLabel) +
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
escapeHtml(timeStr) +
'</div>' +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(showLabel) +
'">' +
ICON_PHONE +
"</button>" +
"</div>" +
'<div class="duty-flip-back duty-item duty-item--duty duty-timeline-card' +
extraClass +
'">' +
'<span class="name">' +
escapeHtml(d.full_name) +
"</span>" +
contactHtml +
'<button class="duty-flip-btn" type="button" aria-label="' +
escapeHtml(backLabel) +
'">' +
ICON_BACK +
"</button>" +
"</div>" +
"</div></div>"
);
}
@@ -69,14 +131,14 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
if (extraClass) itemClass += " " + extraClass;
let timeOrRange = "";
if (showUntilEnd && d.event_type === "duty") {
timeOrRange = t(lang, "duty.until", { time: formatTimeLocal(d.end_at) });
timeOrRange = t(lang, "duty.until", { time: formatHHMM(d.end_at) });
} else if (d.event_type === "vacation" || d.event_type === "unavailable") {
const startStr = formatDateKey(d.start_at);
const endStr = formatDateKey(d.end_at);
timeOrRange = startStr === endStr ? startStr : startStr + " " + endStr;
} else {
timeOrRange =
formatTimeLocal(d.start_at) + " " + formatTimeLocal(d.end_at);
formatHHMM(d.start_at) + " " + formatHHMM(d.end_at);
}
return (
'<div class="' +
@@ -86,17 +148,33 @@ export function dutyItemHtml(d, typeLabelOverride, showUntilEnd, extraClass) {
'</span> <span class="name">' +
escapeHtml(d.full_name) +
'</span><div class="time">' +
timeOrRange +
escapeHtml(timeOrRange) +
"</div></div>"
);
}
/** Whether the delegated flip-button click listener has been attached to duty list element. */
let flipListenerAttached = false;
/**
* Render duty list (timeline) for current month; scroll to today if visible.
* @param {object[]} duties - Duties (only duty type used for timeline)
*/
export function renderDutyList(duties) {
const dutyListEl = getDutyListEl();
if (!dutyListEl) return;
if (!flipListenerAttached) {
flipListenerAttached = true;
dutyListEl.addEventListener("click", (e) => {
const btn = e.target.closest(".duty-flip-btn");
if (!btn) return;
const card = btn.closest(".duty-flip-card");
if (!card) return;
const flipped = card.getAttribute("data-flipped") === "true";
card.setAttribute("data-flipped", String(!flipped));
});
}
const filtered = duties.filter((d) => d.event_type === "duty");
if (filtered.length === 0) {
dutyListEl.classList.remove("duty-timeline");
@@ -174,8 +252,9 @@ export function renderDutyList(duties) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const currentDutyCard = dutyListEl.querySelector(".duty-item--current");
const todayBlock = dutyListEl.querySelector(".duty-timeline-day--today");
const listEl = getDutyListEl();
const currentDutyCard = listEl ? listEl.querySelector(".duty-item--current") : null;
const todayBlock = listEl ? listEl.querySelector(".duty-timeline-day--today") : null;
if (currentDutyCard) {
scrollToEl(currentDutyCard);
} else if (todayBlock) {

164
webapp/js/dutyList.test.js Normal file
View File

@@ -0,0 +1,164 @@
/**
* Unit tests for dutyList (dutyTimelineCardHtml, dutyItemHtml, contact rendering).
*/
import { describe, it, expect, beforeAll, vi, afterEach } from "vitest";
import * as dateUtils from "./dateUtils.js";
import { dutyTimelineCardHtml, dutyItemHtml } from "./dutyList.js";
describe("dutyList", () => {
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><div id="monthTitle"></div>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
describe("dutyTimelineCardHtml", () => {
it("renders duty with full_name and time range (no flip when no contacts)", () => {
const d = {
event_type: "duty",
full_name: "Иванов",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T18:00:00",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Иванов");
expect(html).toContain("duty-item");
expect(html).toContain("duty-timeline-card");
expect(html).not.toContain("duty-flip-card");
expect(html).not.toContain("duty-flip-btn");
});
it("uses flip-card wrapper with front and back when phone or username present", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
phone: "+79991234567",
username: "alice_dev",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Alice");
expect(html).toContain("duty-flip-card");
expect(html).toContain("duty-flip-inner");
expect(html).toContain("duty-flip-front");
expect(html).toContain("duty-flip-back");
expect(html).toContain("duty-flip-btn");
expect(html).toContain('data-flipped="false"');
expect(html).toContain("duty-contact-row");
expect(html).toContain('href="tel:');
expect(html).toContain("+79991234567");
expect(html).toContain("https://t.me/");
expect(html).toContain("alice_dev");
});
it("front face contains name and time; back face contains contact links", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-02T08:00:00",
end_at: "2025-03-02T16:00:00",
phone: "+79001112233",
};
const html = dutyTimelineCardHtml(d, false);
const frontStart = html.indexOf("duty-flip-front");
const backStart = html.indexOf("duty-flip-back");
const frontSection = html.slice(frontStart, backStart);
const backSection = html.slice(backStart);
expect(frontSection).toContain("Bob");
expect(frontSection).toContain("time");
expect(frontSection).not.toContain("duty-contact-row");
expect(backSection).toContain("Bob");
expect(backSection).toContain("duty-contact-row");
expect(backSection).toContain("tel:");
});
it("omits flip wrapper and button when phone and username are missing", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-02T08:00:00",
end_at: "2025-03-02T16:00:00",
};
const html = dutyTimelineCardHtml(d, false);
expect(html).toContain("Bob");
expect(html).not.toContain("duty-flip-card");
expect(html).not.toContain("duty-flip-btn");
expect(html).not.toContain("duty-contact-row");
});
});
describe("dutyItemHtml", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("escapes timeOrRange so HTML special chars are not rendered raw", () => {
vi.spyOn(dateUtils, "formatHHMM").mockReturnValue("12:00 & 13:00");
vi.spyOn(dateUtils, "formatDateKey").mockReturnValue("01.02.2025");
const d = {
event_type: "duty",
full_name: "Test",
start_at: "2025-03-01T12:00:00",
end_at: "2025-03-01T13:00:00",
};
const html = dutyItemHtml(d, null, false);
expect(html).toContain("&amp;");
expect(html).not.toContain('<div class="time">12:00 & 13:00');
});
it("uses typeLabelOverride when provided", () => {
const d = {
event_type: "duty",
full_name: "Alice",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, "On duty now", false);
expect(html).toContain("On duty now");
expect(html).toContain("Alice");
});
it("shows duty.until when showUntilEnd is true for duty", () => {
const d = {
event_type: "duty",
full_name: "Bob",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, true);
expect(html).toMatch(/until|до/);
expect(html).toMatch(/\d{2}:\d{2}/);
});
it("renders vacation with date range", () => {
vi.spyOn(dateUtils, "formatDateKey")
.mockReturnValueOnce("01.03")
.mockReturnValueOnce("05.03");
const d = {
event_type: "vacation",
full_name: "Charlie",
start_at: "2025-03-01T00:00:00",
end_at: "2025-03-05T23:59:59",
};
const html = dutyItemHtml(d);
expect(html).toContain("01.03 05.03");
expect(html).toContain("duty-item--vacation");
});
it("applies extraClass to container", () => {
const d = {
event_type: "duty",
full_name: "Dana",
start_at: "2025-03-01T09:00:00",
end_at: "2025-03-01T17:00:00",
};
const html = dutyItemHtml(d, null, false, "duty-item--current");
expect(html).toContain("duty-item--current");
expect(html).toContain("Dana");
});
});
});

View File

@@ -2,7 +2,7 @@
* Tooltips for calendar info buttons and duty markers.
*/
import { calendarEl, state } from "./dom.js";
import { getCalendarEl, state } from "./dom.js";
import { t } from "./i18n.js";
import { escapeHtml } from "./utils.js";
import { localDateString, formatHHMM } from "./dateUtils.js";
@@ -115,13 +115,25 @@ function buildDutyItemTimePrefix(item, idx, total, hintDay, sep, fromLabel, toLa
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (startSameDay && startHHMM) {
/* First of multiple, but starts today — show full range */
timePrefix = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
/* Continuation from previous day — only end time */
timePrefix = toLabel + sep + endHHMM;
}
} else if (idx > 0) {
if (startHHMM) timePrefix = fromLabel + sep + startHHMM;
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += (timePrefix ? " " : "") + toLabel + sep + endHHMM;
if (startSameDay && startHHMM) {
timePrefix = fromLabel + sep + startHHMM;
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
/* Continuation from previous day — only end time */
timePrefix = toLabel + sep + endHHMM;
}
}
return timePrefix;
@@ -238,6 +250,7 @@ export function getDutyMarkerHintHtml(marker) {
* Remove active class from all duty/unavailable/vacation markers.
*/
export function clearActiveDutyMarker() {
const calendarEl = getCalendarEl();
if (!calendarEl) return;
calendarEl
.querySelectorAll(
@@ -246,148 +259,168 @@ export function clearActiveDutyMarker() {
.forEach((m) => m.classList.remove("calendar-marker-active"));
}
/** Timeout for hiding duty marker hint on mouseleave (delegated). */
let dutyMarkerHideTimeout = null;
const HINT_FADE_MS = 150;
/**
* Bind click tooltips for .info-btn (calendar event summaries).
* Dismiss a hint with fade-out: remove visible class, then after delay set hidden and remove data-active.
* @param {HTMLElement} hintEl - The hint element to dismiss
* @param {{ clearActive?: boolean, afterHide?: () => void }} opts - Optional: call clearActiveDutyMarker after hide; callback after hide
* @returns {number} Timeout id (for use with clearTimeout, e.g. when delegating hide to mouseout)
*/
export function bindInfoButtonTooltips() {
let hintEl = document.getElementById("calendarEventHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "calendarEventHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
export function dismissHint(hintEl, opts = {}) {
hintEl.classList.remove("calendar-event-hint--visible");
const id = setTimeout(() => {
hintEl.hidden = true;
document.body.appendChild(hintEl);
}
if (!calendarEl) return;
const lang = state.lang;
calendarEl.querySelectorAll(".info-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(lang, "hint.events") + "\n" + summary;
if (hintEl.hidden || hintEl.textContent !== content) {
hintEl.textContent = content;
const rect = btn.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.dataset.active = "1";
} else {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
});
if (!document._calendarHintBound) {
document._calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
}
});
hintEl.removeAttribute("data-active");
if (opts.clearActive) clearActiveDutyMarker();
if (typeof opts.afterHide === "function") opts.afterHide();
}, HINT_FADE_MS);
return id;
}
const DUTY_MARKER_SELECTOR = ".duty-marker, .unavailable-marker, .vacation-marker";
/**
* Get or create the calendar event (info button) hint element.
* @returns {HTMLElement|null}
*/
function getOrCreateCalendarEventHint() {
let el = document.getElementById("calendarEventHint");
if (!el) {
el = document.createElement("div");
el.id = "calendarEventHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Bind hover/click tooltips for duty/unavailable/vacation markers.
* Get or create the duty marker hint element.
* @returns {HTMLElement|null}
*/
export function bindDutyMarkerTooltips() {
let hintEl = document.getElementById("dutyMarkerHint");
if (!hintEl) {
hintEl = document.createElement("div");
hintEl.id = "dutyMarkerHint";
hintEl.className = "calendar-event-hint";
hintEl.setAttribute("role", "tooltip");
hintEl.hidden = true;
document.body.appendChild(hintEl);
function getOrCreateDutyMarkerHint() {
let el = document.getElementById("dutyMarkerHint");
if (!el) {
el = document.createElement("div");
el.id = "dutyMarkerHint";
el.className = "calendar-event-hint";
el.setAttribute("role", "tooltip");
el.hidden = true;
document.body.appendChild(el);
}
return el;
}
/**
* Set up event delegation on calendarEl for info button and duty marker tooltips.
* Call once at startup (e.g. alongside initDayDetail). No need to re-bind after render.
*/
export function initHints() {
const calendarEventHint = getOrCreateCalendarEventHint();
const dutyMarkerHint = getOrCreateDutyMarkerHint();
const calendarEl = getCalendarEl();
if (!calendarEl) return;
let hideTimeout = null;
const selector = ".duty-marker, .unavailable-marker, .vacation-marker";
calendarEl.querySelectorAll(selector).forEach((marker) => {
marker.addEventListener("mouseenter", () => {
if (hideTimeout) {
clearTimeout(hideTimeout);
hideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
calendarEl.addEventListener("click", (e) => {
const btn = e.target instanceof HTMLElement ? e.target.closest(".info-btn") : null;
if (btn) {
e.stopPropagation();
const summary = btn.getAttribute("data-summary") || "";
const content = t(state.lang, "hint.events") + "\n" + summary;
if (calendarEventHint.hidden || calendarEventHint.textContent !== content) {
calendarEventHint.textContent = content;
positionHint(calendarEventHint, btn.getBoundingClientRect());
calendarEventHint.dataset.active = "1";
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
dismissHint(calendarEventHint);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
});
marker.addEventListener("mouseleave", () => {
if (hintEl.dataset.active) return;
hintEl.classList.remove("calendar-event-hint--visible");
hideTimeout = setTimeout(() => {
hintEl.hidden = true;
hideTimeout = null;
}, 150);
});
marker.addEventListener("click", (e) => {
return;
}
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (marker) {
e.stopPropagation();
if (marker.classList.contains("calendar-marker-active")) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
}, 150);
dismissHint(dutyMarkerHint);
marker.classList.remove("calendar-marker-active");
return;
}
clearActiveDutyMarker();
const html = getDutyMarkerHintHtml(marker);
if (html) {
hintEl.innerHTML = html;
dutyMarkerHint.innerHTML = html;
} else {
hintEl.textContent = getDutyMarkerHintContent(marker);
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
const rect = marker.getBoundingClientRect();
positionHint(hintEl, rect);
hintEl.hidden = false;
hintEl.dataset.active = "1";
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
dutyMarkerHint.dataset.active = "1";
marker.classList.add("calendar-marker-active");
}
});
calendarEl.addEventListener("mouseover", (e) => {
const marker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!marker) return;
const related = e.relatedTarget instanceof Node ? e.relatedTarget : null;
if (related && marker.contains(related)) return;
if (dutyMarkerHideTimeout) {
clearTimeout(dutyMarkerHideTimeout);
dutyMarkerHideTimeout = null;
}
const html = getDutyMarkerHintHtml(marker);
if (html) {
dutyMarkerHint.innerHTML = html;
} else {
dutyMarkerHint.textContent = getDutyMarkerHintContent(marker);
}
positionHint(dutyMarkerHint, marker.getBoundingClientRect());
dutyMarkerHint.hidden = false;
});
calendarEl.addEventListener("mouseout", (e) => {
const fromMarker = e.target instanceof HTMLElement ? e.target.closest(DUTY_MARKER_SELECTOR) : null;
if (!fromMarker) return;
const toMarker = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest(DUTY_MARKER_SELECTOR) : null;
if (toMarker) return;
if (dutyMarkerHint.dataset.active) return;
dutyMarkerHideTimeout = dismissHint(dutyMarkerHint, {
afterHide: () => {
dutyMarkerHideTimeout = null;
},
});
});
if (!document._dutyMarkerHintBound) {
document._dutyMarkerHintBound = true;
if (!state.calendarHintBound) {
state.calendarHintBound = true;
document.addEventListener("click", () => {
if (hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
if (calendarEventHint.dataset.active) {
dismissHint(calendarEventHint);
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && hintEl.dataset.active) {
hintEl.classList.remove("calendar-event-hint--visible");
setTimeout(() => {
hintEl.hidden = true;
hintEl.removeAttribute("data-active");
clearActiveDutyMarker();
}, 150);
if (e.key === "Escape" && calendarEventHint.dataset.active) {
dismissHint(calendarEventHint);
}
});
}
if (!state.dutyMarkerHintBound) {
state.dutyMarkerHintBound = true;
document.addEventListener("click", () => {
if (dutyMarkerHint.dataset.active) {
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && dutyMarkerHint.dataset.active) {
dismissHint(dutyMarkerHint, { clearActive: true });
}
});
}

176
webapp/js/hints.test.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* Unit tests for getDutyMarkerRows and buildDutyItemTimePrefix logic.
* Covers: sorting order preservation, idx=0 with total>1 and startSameDay.
* Also tests dismissHint helper.
*/
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from "vitest";
import { getDutyMarkerRows, dismissHint } from "./hints.js";
const FROM = "from";
const TO = "until";
const SEP = "\u00a0";
describe("getDutyMarkerRows", () => {
beforeAll(() => {
document.body.innerHTML = '<div id="calendar"></div>';
});
it("preserves input order (caller must sort by start_at before passing)", () => {
const hintDay = "2025-02-25";
const duties = [
{
full_name: "Иванов",
start_at: "2025-02-25T14:00:00",
end_at: "2025-02-25T18:00:00",
},
{
full_name: "Петров",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T14:00:00",
},
];
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[1].fullName).toBe("Петров");
});
it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => {
const hintDay = "2025-02-25";
const duties = [
{
full_name: "Иванов",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T14:00:00",
},
{
full_name: "Петров",
start_at: "2025-02-25T14:00:00",
end_at: "2025-02-25T18:00:00",
},
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[0].timePrefix).toContain("14:00");
expect(rows[0].timePrefix).toContain(FROM);
expect(rows[0].timePrefix).toContain(TO);
});
it("first of multiple continuation from previous day shows only end time", () => {
const hintDay = "2025-02-25";
const duties = [
{
full_name: "Иванов",
start_at: "2025-02-24T22:00:00",
end_at: "2025-02-25T06:00:00",
},
{
full_name: "Петров",
start_at: "2025-02-25T09:00:00",
end_at: "2025-02-25T14:00:00",
},
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[0].timePrefix).not.toContain(FROM);
expect(rows[0].timePrefix).toContain(TO);
expect(rows[0].timePrefix).toContain("06:00");
});
it("second duty continuation from previous day shows only end time (to HH:MM)", () => {
const hintDay = "2025-02-23";
const duties = [
{
full_name: "A",
start_at: "2025-02-23T00:00:00",
end_at: "2025-02-23T09:00:00",
},
{
full_name: "B",
start_at: "2025-02-22T09:00:00",
end_at: "2025-02-23T09:00:00",
},
];
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("A");
expect(rows[0].timePrefix).toContain(FROM);
expect(rows[0].timePrefix).toContain("00:00");
expect(rows[0].timePrefix).toContain(TO);
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[1].fullName).toBe("B");
expect(rows[1].timePrefix).not.toContain(FROM);
expect(rows[1].timePrefix).toContain(TO);
expect(rows[1].timePrefix).toContain("09:00");
});
it("multiple duties in one day — correct order when input is pre-sorted", () => {
const hintDay = "2025-02-25";
const duties = [
{ full_name: "A", start_at: "2025-02-25T09:00:00", end_at: "2025-02-25T12:00:00" },
{ full_name: "B", start_at: "2025-02-25T12:00:00", end_at: "2025-02-25T15:00:00" },
{ full_name: "C", start_at: "2025-02-25T15:00:00", end_at: "2025-02-25T18:00:00" },
].sort((a, b) => new Date(a.start_at) - new Date(b.start_at));
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]);
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[1].timePrefix).toContain("12:00");
expect(rows[2].timePrefix).toContain("15:00");
});
});
describe("dismissHint", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("removes visible class immediately and hides element after delay", () => {
const el = document.createElement("div");
el.classList.add("calendar-event-hint--visible");
el.hidden = false;
el.setAttribute("data-active", "1");
dismissHint(el);
expect(el.classList.contains("calendar-event-hint--visible")).toBe(false);
expect(el.hidden).toBe(false);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(true);
expect(el.hasAttribute("data-active")).toBe(false);
});
it("returns timeout id usable with clearTimeout", () => {
const el = document.createElement("div");
const id = dismissHint(el);
expect(id).toBeDefined();
clearTimeout(id);
vi.advanceTimersByTime(150);
expect(el.hidden).toBe(false);
});
it("calls afterHide callback after delay when provided", () => {
const el = document.createElement("div");
let called = false;
dismissHint(el, {
afterHide: () => {
called = true;
},
});
expect(called).toBe(false);
vi.advanceTimersByTime(150);
expect(called).toBe(true);
});
});

View File

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

87
webapp/js/i18n.test.js Normal file
View File

@@ -0,0 +1,87 @@
/**
* Unit tests for i18n: getLang, t (fallback, params), monthName.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockGetInitData = vi.fn();
vi.mock("./auth.js", () => ({ getInitData: () => mockGetInitData() }));
import { getLang, t, monthName, MESSAGES } from "./i18n.js";
describe("getLang", () => {
const origNavigator = globalThis.navigator;
beforeEach(() => {
mockGetInitData.mockReset();
});
it("returns lang from initData user when present", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "en" }))
);
expect(getLang()).toBe("en");
});
it("normalizes ru from initData", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "ru" }))
);
expect(getLang()).toBe("ru");
});
it("falls back to navigator.language when initData empty", () => {
mockGetInitData.mockReturnValue("");
Object.defineProperty(globalThis, "navigator", {
value: { ...origNavigator, language: "en-US", languages: ["en-US", "en"] },
configurable: true,
});
expect(getLang()).toBe("en");
});
it("normalizes to en for unknown language code", () => {
mockGetInitData.mockReturnValue(
"user=" + encodeURIComponent(JSON.stringify({ language_code: "uk" }))
);
expect(getLang()).toBe("en");
});
});
describe("t", () => {
it("returns translation for existing key", () => {
expect(t("en", "app.title")).toBe("Duty Calendar");
expect(t("ru", "app.title")).toBe("Календарь дежурств");
});
it("falls back to en when key missing in lang", () => {
expect(t("ru", "app.title")).toBe("Календарь дежурств");
expect(t("en", "loading")).toBe("Loading…");
});
it("returns key when key missing in both", () => {
expect(t("en", "missing.key")).toBe("missing.key");
expect(t("ru", "unknown")).toBe("unknown");
});
it("replaces params placeholder", () => {
expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
});
it("handles empty params", () => {
expect(t("en", "loading", {})).toBe("Loading…");
});
});
describe("monthName", () => {
it("returns month name for 0-based index", () => {
expect(monthName("en", 0)).toBe("January");
expect(monthName("en", 11)).toBe("December");
expect(monthName("ru", 0)).toBe("Январь");
});
it("returns empty string for out-of-range", () => {
expect(monthName("en", 12)).toBe("");
expect(monthName("en", -1)).toBe("");
});
});

View File

@@ -4,17 +4,16 @@
import { initTheme, applyTheme } from "./theme.js";
import { getLang, t, weekdayLabels } from "./i18n.js";
import { getInitData } from "./auth.js";
import { isLocalhost } from "./auth.js";
import { getInitData, isLocalhost } from "./auth.js";
import { RETRY_DELAY_MS, RETRY_AFTER_ACCESS_DENIED_MS } from "./constants.js";
import {
state,
accessDeniedEl,
prevBtn,
nextBtn,
loadingEl,
errorEl,
weekdaysEl
getAccessDeniedEl,
getPrevBtn,
getNextBtn,
getLoadingEl,
getErrorEl,
getWeekdaysEl
} from "./dom.js";
import { showAccessDenied, hideAccessDenied, showError, setNavEnabled } from "./ui.js";
import { fetchDuties, fetchCalendarEvents } from "./api.js";
@@ -24,7 +23,9 @@ import {
renderCalendar
} from "./calendar.js";
import { initDayDetail } from "./dayDetail.js";
import { initHints } from "./hints.js";
import { renderDutyList } from "./dutyList.js";
import { showCurrentDutyView, hideCurrentDutyView } from "./currentDuty.js";
import {
firstDayOfMonth,
lastDayOfMonth,
@@ -38,15 +39,19 @@ initTheme();
state.lang = getLang();
document.documentElement.lang = state.lang;
document.title = t(state.lang, "app.title");
const loadingEl = getLoadingEl();
const loadingTextEl = loadingEl ? loadingEl.querySelector(".loading__text") : null;
if (loadingTextEl) loadingTextEl.textContent = t(state.lang, "loading");
const dayLabels = weekdayLabels(state.lang);
const weekdaysEl = getWeekdaysEl();
if (weekdaysEl) {
const spans = weekdaysEl.querySelectorAll("span");
spans.forEach((span, i) => {
if (dayLabels[i]) span.textContent = dayLabels[i];
});
}
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.setAttribute("aria-label", t(state.lang, "nav.prev_month"));
if (nextBtn) nextBtn.setAttribute("aria-label", t(state.lang, "nav.next_month"));
@@ -98,21 +103,33 @@ function requireTelegramOrLocalhost(onAllowed) {
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}, RETRY_DELAY_MS);
return;
}
showAccessDenied(undefined);
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
}
/** AbortController for the in-flight loadMonth request; aborted when a new load starts. */
let loadMonthAbortController = null;
/**
* Load current month: fetch duties and events, render calendar and duty list.
* Stale requests are cancelled when the user navigates to another month before they complete.
*/
async function loadMonth() {
if (loadMonthAbortController) loadMonthAbortController.abort();
loadMonthAbortController = new AbortController();
const signal = loadMonthAbortController.signal;
hideAccessDenied();
setNavEnabled(false);
const loadingEl = getLoadingEl();
if (loadingEl) loadingEl.classList.remove("hidden");
const errorEl = getErrorEl();
if (errorEl) errorEl.hidden = true;
const current = state.current;
const first = firstDayOfMonth(current);
@@ -122,8 +139,8 @@ async function loadMonth() {
const from = localDateString(start);
const to = localDateString(gridEnd);
try {
const dutiesPromise = fetchDuties(from, to);
const eventsPromise = fetchCalendarEvents(from, to);
const dutiesPromise = fetchDuties(from, to, signal);
const eventsPromise = fetchCalendarEvents(from, to, signal);
const duties = await dutiesPromise;
const events = await eventsPromise;
const byDate = dutiesByDate(duties);
@@ -156,15 +173,18 @@ async function loadMonth() {
}, 60000);
}
} catch (e) {
if (e.name === "AbortError") {
return;
}
if (e.message === "ACCESS_DENIED") {
showAccessDenied(e.serverDetail);
setNavEnabled(true);
if (
window.Telegram &&
window.Telegram.WebApp &&
!window._initDataRetried
!state.initDataRetried
) {
window._initDataRetried = true;
state.initDataRetried = true;
setTimeout(loadMonth, RETRY_AFTER_ACCESS_DENIED_MS);
}
return;
@@ -173,21 +193,26 @@ async function loadMonth() {
setNavEnabled(true);
return;
}
if (loadingEl) loadingEl.classList.add("hidden");
const loading = getLoadingEl();
if (loading) loading.classList.add("hidden");
setNavEnabled(true);
}
if (prevBtn) {
prevBtn.addEventListener("click", () => {
const prevBtnEl = getPrevBtn();
if (prevBtnEl) {
prevBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() - 1);
loadMonth();
});
}
if (nextBtn) {
nextBtn.addEventListener("click", () => {
const nextBtnEl = getNextBtn();
if (nextBtnEl) {
nextBtnEl.addEventListener("click", () => {
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
state.current.setMonth(state.current.getMonth() + 1);
loadMonth();
@@ -204,9 +229,9 @@ if (nextBtn) {
"touchstart",
(e) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
startX = t.clientX;
startY = t.clientY;
const touch = e.changedTouches[0];
startX = touch.clientX;
startY = touch.clientY;
},
{ passive: true }
);
@@ -215,12 +240,15 @@ if (nextBtn) {
(e) => {
if (e.changedTouches.length === 0) return;
if (document.body.classList.contains("day-detail-sheet-open")) return;
const accessDeniedEl = getAccessDeniedEl();
if (accessDeniedEl && !accessDeniedEl.hidden) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX;
const deltaY = t.clientY - startY;
const touch = e.changedTouches[0];
const deltaX = touch.clientX - startX;
const deltaY = touch.clientY - startY;
if (Math.abs(deltaX) <= SWIPE_THRESHOLD) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (deltaX > SWIPE_THRESHOLD) {
if (prevBtn && prevBtn.disabled) return;
state.current.setMonth(state.current.getMonth() - 1);
@@ -237,8 +265,8 @@ if (nextBtn) {
function bindStickyScrollShadow() {
const stickyEl = document.getElementById("calendarSticky");
if (!stickyEl || document._stickyScrollBound) return;
document._stickyScrollBound = true;
if (!stickyEl || state.stickyScrollBound) return;
state.stickyScrollBound = true;
function updateScrolled() {
stickyEl.classList.toggle("is-scrolled", window.scrollY > 0);
}
@@ -250,6 +278,19 @@ runWhenReady(() => {
requireTelegramOrLocalhost(() => {
bindStickyScrollShadow();
initDayDetail();
loadMonth();
initHints();
const startParam =
(window.Telegram && window.Telegram.WebApp && window.Telegram.WebApp.initDataUnsafe &&
window.Telegram.WebApp.initDataUnsafe.start_param) ||
"";
if (startParam === "duty") {
state.lang = getLang();
showCurrentDutyView(() => {
hideCurrentDutyView();
loadMonth();
});
} else {
loadMonth();
}
});
});

152
webapp/js/theme.test.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* Unit tests for theme: getTheme, applyThemeParamsToCss, applyTheme, initTheme.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("theme", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("getTheme", () => {
it("returns Telegram.WebApp.colorScheme when set", async () => {
globalThis.window.Telegram = { WebApp: { colorScheme: "light" } };
vi.spyOn(document.documentElement.style, "getPropertyValue").mockReturnValue("");
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to --tg-color-scheme CSS when TWA has no colorScheme", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue("dark"),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("falls back to matchMedia prefers-color-scheme dark", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
it("returns light when matchMedia prefers light", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockReturnValue({
getPropertyValue: vi.fn().mockReturnValue(""),
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("light");
});
it("falls back to matchMedia when getComputedStyle throws", async () => {
globalThis.window.Telegram = { WebApp: {} };
vi.spyOn(globalThis, "getComputedStyle").mockImplementation(() => {
throw new Error("getComputedStyle not available");
});
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
const { getTheme } = await import("./theme.js");
expect(getTheme()).toBe("dark");
});
});
describe("applyThemeParamsToCss", () => {
it("does nothing when Telegram.WebApp or themeParams missing", async () => {
globalThis.window.Telegram = undefined;
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).not.toHaveBeenCalled();
});
it("sets --tg-theme-* CSS variables from themeParams", async () => {
globalThis.window.Telegram = {
WebApp: {
themeParams: {
bg_color: "#ffffff",
text_color: "#000000",
hint_color: "#888888",
},
},
};
const setProperty = vi.fn();
document.documentElement.style.setProperty = setProperty;
const { applyThemeParamsToCss } = await import("./theme.js");
applyThemeParamsToCss();
expect(setProperty).toHaveBeenCalledWith("--tg-theme-bg-color", "#ffffff");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-text-color", "#000000");
expect(setProperty).toHaveBeenCalledWith("--tg-theme-hint-color", "#888888");
});
});
describe("applyTheme", () => {
beforeEach(() => {
document.documentElement.dataset.theme = "";
});
it("sets data-theme on documentElement from getTheme", async () => {
const theme = await import("./theme.js");
vi.spyOn(theme, "getTheme").mockReturnValue("light");
theme.applyTheme();
expect(document.documentElement.dataset.theme).toBe("light");
});
it("calls setBackgroundColor and setHeaderColor when TWA present", async () => {
const setBackgroundColor = vi.fn();
const setHeaderColor = vi.fn();
globalThis.window.Telegram = {
WebApp: {
setBackgroundColor: setBackgroundColor,
setHeaderColor: setHeaderColor,
themeParams: null,
},
};
const { applyTheme } = await import("./theme.js");
applyTheme();
expect(setBackgroundColor).toHaveBeenCalledWith("bg_color");
expect(setHeaderColor).toHaveBeenCalledWith("bg_color");
});
});
describe("initTheme", () => {
it("runs without throwing when TWA present", async () => {
globalThis.window.Telegram = { WebApp: {} };
const { initTheme } = await import("./theme.js");
expect(() => initTheme()).not.toThrow();
});
it("adds matchMedia change listener when no TWA", async () => {
globalThis.window.Telegram = undefined;
const addEventListener = vi.fn();
vi.spyOn(globalThis, "matchMedia").mockReturnValue({
matches: false,
addEventListener,
removeEventListener: vi.fn(),
});
const { initTheme } = await import("./theme.js");
initTheme();
expect(addEventListener).toHaveBeenCalledWith("change", expect.any(Function));
});
});
});

View File

@@ -4,15 +4,15 @@
import {
state,
calendarEl,
dutyListEl,
loadingEl,
errorEl,
accessDeniedEl,
headerEl,
weekdaysEl,
prevBtn,
nextBtn
getCalendarEl,
getDutyListEl,
getLoadingEl,
getErrorEl,
getAccessDeniedEl,
getHeaderEl,
getWeekdaysEl,
getPrevBtn,
getNextBtn
} from "./dom.js";
import { t } from "./i18n.js";
@@ -21,6 +21,13 @@ import { t } from "./i18n.js";
* @param {string} [serverDetail] - message from API 403 detail (shown below main text when present)
*/
export function showAccessDenied(serverDetail) {
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
const loadingEl = getLoadingEl();
const errorEl = getErrorEl();
const accessDeniedEl = getAccessDeniedEl();
if (headerEl) headerEl.hidden = true;
if (weekdaysEl) weekdaysEl.hidden = true;
if (calendarEl) calendarEl.hidden = true;
@@ -44,6 +51,11 @@ export function showAccessDenied(serverDetail) {
* Hide access-denied and show calendar/list/header/weekdays.
*/
export function hideAccessDenied() {
const accessDeniedEl = getAccessDeniedEl();
const headerEl = getHeaderEl();
const weekdaysEl = getWeekdaysEl();
const calendarEl = getCalendarEl();
const dutyListEl = getDutyListEl();
if (accessDeniedEl) accessDeniedEl.hidden = true;
if (headerEl) headerEl.hidden = false;
if (weekdaysEl) weekdaysEl.hidden = false;
@@ -56,6 +68,8 @@ export function hideAccessDenied() {
* @param {string} msg - Error text
*/
export function showError(msg) {
const errorEl = getErrorEl();
const loadingEl = getLoadingEl();
if (errorEl) {
errorEl.textContent = msg;
errorEl.hidden = false;
@@ -68,6 +82,8 @@ export function showError(msg) {
* @param {boolean} enabled
*/
export function setNavEnabled(enabled) {
const prevBtn = getPrevBtn();
const nextBtn = getNextBtn();
if (prevBtn) prevBtn.disabled = !enabled;
if (nextBtn) nextBtn.disabled = !enabled;
}

122
webapp/js/ui.test.js Normal file
View File

@@ -0,0 +1,122 @@
/**
* Unit tests for ui: showAccessDenied, hideAccessDenied, showError, setNavEnabled.
*/
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
beforeAll(() => {
document.body.innerHTML =
'<div id="calendar"></div><h2 id="monthTitle"></h2>' +
'<div id="dutyList"></div><div id="loading"></div><div id="error"></div>' +
'<div id="accessDenied"></div><div class="header"></div><div class="weekdays"></div>' +
'<button id="prevMonth"></button><button id="nextMonth"></button>';
});
import {
showAccessDenied,
hideAccessDenied,
showError,
setNavEnabled,
} from "./ui.js";
import { state } from "./dom.js";
describe("ui", () => {
beforeEach(() => {
state.lang = "ru";
const calendar = document.getElementById("calendar");
const dutyList = document.getElementById("dutyList");
const loading = document.getElementById("loading");
const error = document.getElementById("error");
const accessDenied = document.getElementById("accessDenied");
const header = document.querySelector(".header");
const weekdays = document.querySelector(".weekdays");
const prevBtn = document.getElementById("prevMonth");
const nextBtn = document.getElementById("nextMonth");
if (header) header.hidden = false;
if (weekdays) weekdays.hidden = false;
if (calendar) calendar.hidden = false;
if (dutyList) dutyList.hidden = false;
if (loading) loading.classList.remove("hidden");
if (error) error.hidden = true;
if (accessDenied) accessDenied.hidden = true;
if (prevBtn) prevBtn.disabled = false;
if (nextBtn) nextBtn.disabled = false;
});
describe("showAccessDenied", () => {
it("hides header, weekdays, calendar, dutyList, loading, error and shows accessDenied", () => {
showAccessDenied();
expect(document.querySelector(".header")?.hidden).toBe(true);
expect(document.querySelector(".weekdays")?.hidden).toBe(true);
expect(document.getElementById("calendar")?.hidden).toBe(true);
expect(document.getElementById("dutyList")?.hidden).toBe(true);
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
expect(document.getElementById("error")?.hidden).toBe(true);
expect(document.getElementById("accessDenied")?.hidden).toBe(false);
});
it("sets accessDenied innerHTML with translated message", () => {
showAccessDenied();
const el = document.getElementById("accessDenied");
expect(el?.innerHTML).toContain("Доступ запрещён");
});
it("appends serverDetail in .access-denied-detail when provided", () => {
showAccessDenied("Custom 403 message");
const el = document.getElementById("accessDenied");
const detail = el?.querySelector(".access-denied-detail");
expect(detail?.textContent).toBe("Custom 403 message");
});
it("does not append detail element when serverDetail is empty string", () => {
showAccessDenied("");
const el = document.getElementById("accessDenied");
expect(el?.querySelector(".access-denied-detail")).toBeNull();
});
});
describe("hideAccessDenied", () => {
it("hides accessDenied and shows header, weekdays, calendar, dutyList", () => {
document.getElementById("accessDenied").hidden = false;
document.querySelector(".header").hidden = true;
document.getElementById("calendar").hidden = true;
hideAccessDenied();
expect(document.getElementById("accessDenied")?.hidden).toBe(true);
expect(document.querySelector(".header")?.hidden).toBe(false);
expect(document.querySelector(".weekdays")?.hidden).toBe(false);
expect(document.getElementById("calendar")?.hidden).toBe(false);
expect(document.getElementById("dutyList")?.hidden).toBe(false);
});
});
describe("showError", () => {
it("sets error text and shows error element", () => {
showError("Network error");
const errorEl = document.getElementById("error");
expect(errorEl?.textContent).toBe("Network error");
expect(errorEl?.hidden).toBe(false);
});
it("adds hidden class to loading element", () => {
document.getElementById("loading").classList.remove("hidden");
showError("Fail");
expect(document.getElementById("loading")?.classList.contains("hidden")).toBe(true);
});
});
describe("setNavEnabled", () => {
it("disables prev and next buttons when enabled is false", () => {
setNavEnabled(false);
expect(document.getElementById("prevMonth")?.disabled).toBe(true);
expect(document.getElementById("nextMonth")?.disabled).toBe(true);
});
it("enables prev and next buttons when enabled is true", () => {
document.getElementById("prevMonth").disabled = true;
document.getElementById("nextMonth").disabled = true;
setNavEnabled(true);
expect(document.getElementById("prevMonth")?.disabled).toBe(false);
expect(document.getElementById("nextMonth")?.disabled).toBe(false);
});
});
});

View File

@@ -2,13 +2,19 @@
* Common utilities.
*/
const ESCAPE_MAP = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
/**
* Escape string for safe use in HTML (text content / attributes).
* @param {string} s - Raw string
* @returns {string} HTML-escaped string
*/
export function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
return String(s).replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}

42
webapp/js/utils.test.js Normal file
View File

@@ -0,0 +1,42 @@
/**
* Unit tests for escapeHtml edge cases.
*/
import { describe, it, expect } from "vitest";
import { escapeHtml } from "./utils.js";
describe("escapeHtml", () => {
it("escapes ampersand", () => {
expect(escapeHtml("a & b")).toBe("a &amp; b");
});
it("escapes less-than and greater-than", () => {
expect(escapeHtml("<script>")).toBe("&lt;script&gt;");
});
it("escapes double quote", () => {
expect(escapeHtml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("escapes single quote", () => {
expect(escapeHtml("it's")).toBe("it&#39;s");
});
it("escapes all special chars together", () => {
expect(escapeHtml('&<>"\'')).toBe("&amp;&lt;&gt;&quot;&#39;");
});
it("returns unchanged string when no special chars", () => {
expect(escapeHtml("plain text")).toBe("plain text");
});
it("handles empty string", () => {
expect(escapeHtml("")).toBe("");
});
it("coerces non-string to string", () => {
expect(escapeHtml(123)).toBe("123");
expect(escapeHtml(null)).toBe("null");
expect(escapeHtml(undefined)).toBe("undefined");
});
});

12
webapp/package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "duty-teller-webapp",
"type": "module",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"happy-dom": "^15.0.0",
"vitest": "^2.0.0"
}
}

View File

@@ -1,882 +0,0 @@
/* === Variables & themes */
:root {
--bg: #1a1b26;
--surface: #24283b;
--text: #c0caf5;
--muted: #565f89;
--accent: #7aa2f7;
--duty: #9ece6a;
--today: #bb9af7;
--unavailable: #e0af68;
--vacation: #7dcfff;
--error: #f7768e;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Light theme: prefer Telegram themeParams (--tg-theme-*), fallback to Telegram-like palette */
[data-theme="light"] {
--bg: var(--tg-theme-bg-color, #f0f1f3);
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
--text: var(--tg-theme-text-color, #343b58);
--muted: var(--tg-theme-hint-color, #6b7089);
--accent: var(--tg-theme-link-color, #2e7de0);
--duty: #587d0a;
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
--error: #c43b3b;
}
/* Dark theme: prefer Telegram themeParams, fallback to Telegram dark palette */
[data-theme="dark"] {
--bg: var(--tg-theme-bg-color, #17212b);
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
--text: var(--tg-theme-text-color, #f5f5f5);
--muted: var(--tg-theme-hint-color, #708499);
--accent: var(--tg-theme-link-color, #6ab3f3);
--today: var(--tg-theme-link-color, var(--tg-theme-accent-text-color, #6ab2f2));
--duty: #5c9b4a;
--unavailable: #b8860b;
--vacation: #5a9bb8;
--error: #e06c75;
}
/* === Layout & base */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-tap-highlight-color: transparent;
}
.container {
max-width: 420px;
margin: 0 auto;
padding: 12px;
padding-top: 0px;
padding-bottom: env(safe-area-inset-bottom, 12px);
}
[data-theme="light"] .container {
border-radius: 12px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header[hidden],
.weekdays[hidden] {
display: none !important;
}
.nav {
width: 40px;
height: 40px;
border: none;
border-radius: 10px;
background: var(--surface);
color: var(--accent);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: opacity var(--transition-fast), transform var(--transition-fast);
}
.nav:focus {
outline: none;
}
.nav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.nav:active {
transform: scale(0.95);
}
.nav:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.title {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
margin-bottom: 6px;
font-size: 0.75rem;
color: var(--muted);
text-align: center;
}
.calendar-sticky {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding-bottom: 12px;
margin-bottom: 4px;
touch-action: pan-y;
transition: box-shadow var(--transition-fast) ease-out;
}
/* === Calendar grid & day cells */
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 16px;
}
.day {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 4px;
border-radius: 8px;
font-size: 0.85rem;
background: var(--surface);
min-width: 0;
min-height: 0;
overflow: hidden;
transition: background-color var(--transition-fast), transform var(--transition-fast);
}
.day.other-month {
opacity: 0.4;
}
.day.today {
background: var(--today);
color: var(--bg);
}
.day.has-duty .num {
font-weight: 700;
}
.day.holiday {
background: linear-gradient(135deg, var(--surface) 0%, color-mix(in srgb, var(--today) 15%, transparent) 100%);
border: 1px solid color-mix(in srgb, var(--today) 35%, transparent);
}
/* Today + external calendar: same solid "today" look as weekday, plus a border to show it has external events */
.day.today.holiday {
background: var(--today);
color: var(--bg);
border: 1px solid color-mix(in srgb, var(--bg) 50%, transparent);
}
.day {
cursor: pointer;
}
.day:focus {
outline: none;
}
.day:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day:active {
transform: scale(0.98);
}
.day-indicator {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2px;
margin-top: 6px;
}
.day-indicator-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.day-indicator-dot.duty {
background: var(--duty);
}
.day-indicator-dot.unavailable {
background: var(--unavailable);
}
.day-indicator-dot.vacation {
background: var(--vacation);
}
.day-indicator-dot.events {
background: var(--accent);
}
/* On "today" cell: dots darkened for contrast on --today background */
.day.today .day-indicator-dot.duty {
background: color-mix(in srgb, var(--duty) 65%, var(--bg));
}
.day.today .day-indicator-dot.unavailable {
background: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
}
.day.today .day-indicator-dot.vacation {
background: color-mix(in srgb, var(--vacation) 65%, var(--bg));
}
.day.today .day-indicator-dot.events {
background: color-mix(in srgb, var(--accent) 65%, var(--bg));
}
/* === Day detail panel (popover / bottom sheet) */
/* Блокировка фона при открытом bottom sheet: прокрутка и свайпы отключены */
body.day-detail-sheet-open {
position: fixed;
left: 0;
right: 0;
overflow: hidden;
}
.day-detail-overlay {
position: fixed;
inset: 0;
z-index: 999;
background: rgba(0, 0, 0, 0.4);
-webkit-tap-highlight-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-normal) ease-out;
}
.day-detail-overlay.day-detail-overlay--visible {
opacity: 1;
pointer-events: auto;
}
.day-detail-panel {
position: fixed;
z-index: 1000;
max-width: min(360px, calc(100vw - 24px));
max-height: 70vh;
overflow: auto;
background: var(--surface);
color: var(--text);
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
padding: 12px 16px;
padding-top: 36px;
}
.day-detail-panel--sheet {
left: 0;
right: 0;
bottom: 0;
top: auto;
width: 100%;
max-width: none;
max-height: 70vh;
border-radius: 16px 16px 0 0;
padding-top: 12px;
padding-left: 16px;
padding-right: 16px;
/* Комфортный отступ снизу: safe area + дополнительное поле */
padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform var(--transition-normal) var(--ease-out);
}
.day-detail-panel--sheet.day-detail-panel--open {
transform: translateY(0);
}
.day-detail-panel--sheet::before {
content: "";
display: block;
width: 36px;
height: 4px;
margin: 0 auto 8px;
background: var(--muted);
border-radius: 2px;
}
.day-detail-close {
position: absolute;
top: 8px;
right: 8px;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: var(--muted);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
border-radius: 8px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
}
.day-detail-close:focus {
outline: none;
}
.day-detail-close:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.day-detail-close:hover {
color: var(--text);
background: color-mix(in srgb, var(--muted) 25%, transparent);
}
.day-detail-title {
margin: 0 0 12px 0;
font-size: 1.1rem;
font-weight: 600;
}
.day-detail-sections {
display: flex;
flex-direction: column;
gap: 12px;
}
.day-detail-section-title {
margin: 0 0 4px 0;
font-size: 0.8rem;
font-weight: 600;
color: var(--muted);
}
.day-detail-section--duty .day-detail-section-title { color: var(--duty); }
.day-detail-section--unavailable .day-detail-section-title { color: var(--unavailable); }
.day-detail-section--vacation .day-detail-section-title { color: var(--vacation); }
.day-detail-section--events .day-detail-section-title { color: var(--accent); }
.day-detail-list {
margin: 0;
padding-left: 1.2em;
font-size: 0.9rem;
line-height: 1.45;
}
.day-detail-list li {
margin-bottom: 2px;
}
.day-detail-time {
color: var(--muted);
}
.info-btn {
position: absolute;
top: 0;
right: 0;
width: 22px;
height: 22px;
padding: 0;
border: none;
background: var(--accent);
color: var(--bg);
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: flex-start;
justify-content: flex-end;
flex-shrink: 0;
clip-path: path("M 0 0 L 14 0 Q 22 0 22 8 L 22 22 Z");
padding: 2px 3px 0 0;
}
.info-btn:active {
opacity: 0.9;
}
.day-markers {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 2px;
align-items: center;
margin-top: 2px;
min-width: 0;
}
/* === Hints (tooltips) */
.calendar-event-hint {
position: fixed;
z-index: 1000;
width: max-content;
max-width: min(98vw, 900px);
min-width: 0;
padding: 8px 12px;
background: var(--surface);
color: var(--text);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
font-size: 0.85rem;
line-height: 1.4;
white-space: pre;
overflow: visible;
transform: translateY(-100%);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.calendar-event-hint:not(.calendar-event-hint--visible) {
opacity: 0;
}
.calendar-event-hint.calendar-event-hint--visible {
opacity: 1;
}
.calendar-event-hint.below {
transform: none;
}
.calendar-event-hint-title {
margin-bottom: 4px;
font-weight: 600;
}
.calendar-event-hint-rows {
display: table;
width: min-content;
table-layout: auto;
border-collapse: separate;
border-spacing: 0 2px;
}
.calendar-event-hint-row {
display: table-row;
white-space: nowrap;
}
.calendar-event-hint-row .calendar-event-hint-time {
display: table-cell;
white-space: nowrap;
width: 1%;
vertical-align: top;
text-align: right;
padding-right: 0.15em;
}
.calendar-event-hint-row .calendar-event-hint-sep {
display: table-cell;
width: 1em;
vertical-align: top;
padding-right: 0.1em;
}
.calendar-event-hint-row .calendar-event-hint-name {
display: table-cell;
white-space: nowrap !important;
}
/* === Markers (duty / unavailable / vacation) */
.duty-marker,
.unavailable-marker,
.vacation-marker {
display: inline-flex;
align-items: center;
justify-content: center;
width: 11px;
height: 11px;
padding: 0;
border: none;
font-size: 0.55rem;
font-weight: 700;
border-radius: 50%;
flex-shrink: 0;
cursor: pointer;
transition: box-shadow var(--transition-fast) ease-out;
}
.duty-marker {
color: var(--duty);
background: rgba(158, 206, 106, 0.25);
}
.unavailable-marker {
color: var(--unavailable);
background: color-mix(in srgb, var(--unavailable) 25%, transparent);
}
.vacation-marker {
color: var(--vacation);
background: color-mix(in srgb, var(--vacation) 25%, transparent);
}
.duty-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--duty);
}
.unavailable-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--unavailable);
}
.vacation-marker.calendar-marker-active {
box-shadow: 0 0 0 2px var(--vacation);
}
/* === Duty list & timeline */
.duty-list {
font-size: 0.9rem;
}
.duty-list h2 {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 8px 0;
}
.duty-list-day {
margin-bottom: 16px;
}
.duty-list-day--today .duty-list-day-title {
color: var(--today);
font-weight: 700;
}
.duty-list-day--today .duty-list-day-title::before {
content: "";
display: inline-block;
width: 4px;
height: 1em;
background: var(--today);
border-radius: 2px;
margin-right: 8px;
vertical-align: middle;
}
/* Timeline: dates | track (line + dot) | cards */
.duty-list.duty-timeline {
position: relative;
}
.duty-list.duty-timeline::before {
content: "";
position: absolute;
left: calc(var(--timeline-date-width) + var(--timeline-track-width) / 2 - 1px);
top: 0;
bottom: 0;
width: 2px;
background: var(--muted);
pointer-events: none;
}
.duty-timeline-day {
margin-bottom: 0;
}
.duty-timeline-day--today {
scroll-margin-top: 200px;
}
.duty-timeline-row {
display: grid;
grid-template-columns: var(--timeline-date-width) var(--timeline-track-width) 1fr;
gap: 0 4px;
align-items: start;
margin-bottom: 8px;
min-height: 1px;
}
.duty-timeline-date {
position: relative;
font-size: 0.8rem;
color: var(--muted);
padding-top: 10px;
padding-bottom: 10px;
flex-shrink: 0;
overflow: visible;
}
.duty-timeline-date::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
color-mix(in srgb, var(--muted) 40%, transparent) 0%,
color-mix(in srgb, var(--muted) 40%, transparent) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
.duty-timeline-day--today .duty-timeline-date {
display: flex;
flex-direction: column;
align-items: flex-start;
padding-top: 4px;
color: var(--today);
font-weight: 600;
}
.duty-timeline-day--today .duty-timeline-date::before,
.duty-timeline-day--today .duty-timeline-date::after {
display: none;
}
.duty-timeline-date-label,
.duty-timeline-date-day {
display: block;
line-height: 1.25;
}
.duty-timeline-date-day {
align-self: flex-start;
text-align: left;
padding-left: 0;
margin-left: 0;
}
.duty-timeline-date-dot {
display: block;
width: 100%;
height: 8px;
min-height: 8px;
position: relative;
flex-shrink: 0;
}
.duty-timeline-date-dot::before {
content: "";
position: absolute;
left: 0;
top: 50%;
margin-top: -1px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: color-mix(in srgb, var(--today) 45%, transparent);
}
.duty-timeline-date-dot::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
top: 50%;
margin-top: -3px;
width: 2px;
height: 6px;
background: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-label {
color: var(--today);
}
.duty-timeline-day--today .duty-timeline-date .duty-timeline-date-day {
color: var(--muted);
font-weight: 400;
font-size: 0.75rem;
}
.duty-timeline-track {
min-width: 0;
}
.duty-timeline-card-wrap {
min-width: 0;
}
.duty-timeline-card.duty-item,
.duty-list .duty-item {
display: grid;
grid-template-columns: 1fr;
gap: 2px 0;
align-items: baseline;
padding: 8px 10px;
margin-bottom: 0;
border-radius: 8px;
background: var(--surface);
border-left: 3px solid var(--duty);
}
.duty-item--unavailable {
border-left-color: var(--unavailable);
}
.duty-item--vacation {
border-left-color: var(--vacation);
}
[data-theme="dark"] .duty-marker {
background: color-mix(in srgb, var(--duty) 25%, transparent);
}
.duty-item .duty-item-type {
grid-column: 1;
grid-row: 1;
font-size: 0.75rem;
color: var(--muted);
}
.duty-item .name {
grid-column: 2;
grid-row: 1 / -1;
min-width: 0;
font-weight: 600;
}
.duty-item .time {
grid-column: 1;
grid-row: 2;
align-self: start;
font-size: 0.8rem;
color: var(--muted);
}
.duty-timeline-card .duty-item-type { grid-column: 1; grid-row: 1; }
.duty-timeline-card .name { grid-column: 1; grid-row: 2; min-width: 0; }
.duty-timeline-card .time { grid-column: 1; grid-row: 3; }
.duty-item--current {
border-left-color: var(--today);
background: color-mix(in srgb, var(--today) 12%, var(--surface));
}
/* === Loading / error / access denied */
.loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 12px;
color: var(--muted);
text-align: center;
}
.loading__spinner {
display: block;
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: var(--accent);
border-radius: 50%;
animation: loading-spin 0.8s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.loading__spinner {
animation: none;
border-top-color: var(--accent);
border-right-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
.loading, .error {
text-align: center;
padding: 12px;
color: var(--muted);
}
.error,
.access-denied {
transition: opacity 0.2s ease-out;
}
.error {
color: var(--error);
}
.error[hidden], .loading.hidden {
display: none !important;
}
.access-denied {
text-align: center;
padding: 24px 12px;
color: var(--muted);
}
.access-denied p {
margin: 0 0 8px 0;
}
.access-denied p:first-child {
color: var(--error);
font-weight: 600;
}
.access-denied .access-denied-detail {
margin-top: 8px;
font-size: 0.9rem;
color: var(--muted);
}
.access-denied[hidden] {
display: none !important;
}

6
webapp/vitest.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
test: {
environment: "happy-dom",
include: ["js/**/*.test.js"],
},
};