25 Commits

Author SHA1 Message Date
119661628e refactor: enhance styling and structure in DayDetailContent component
All checks were successful
CI / lint-and-test (push) Successful in 1m3s
Docker Build and Release / build-and-push (push) Successful in 55s
Docker Build and Release / release (push) Successful in 14s
- Updated list styling in DayDetailContent to use a cleaner format without bullet points, improving visual consistency.
- Added decorative bullet points for duty, unavailable, vacation, and event summaries to enhance readability.
- Adjusted spacing and layout for better alignment of list items, ensuring a more polished user interface.
2026-03-04 10:06:22 +03:00
336e6d48c5 fix: adjust animation duration for SheetContent component
- Updated the animation duration for the open state of the SheetContent component from 500ms to 300ms, improving the responsiveness of the UI during state transitions.
2026-03-03 19:47:49 +03:00
07d08bb179 feat: release version 2.0.0 with new features and improvements
All checks were successful
CI / lint-and-test (push) Successful in 1m3s
Docker Build and Release / build-and-push (push) Successful in 1m19s
Docker Build and Release / release (push) Successful in 9s
- Added group duty pin functionality with configurable notification settings.
- Introduced `/refresh_pin` command for immediate duty message updates in groups.
- Implemented role-based access management with `/set_role` command for user and admin roles.
- Added `/calendar_link` command to provide users with personal ICS subscription URLs.
- Configured `MINI_APP_SHORT_NAME` for direct Mini App links in pinned messages.
- Introduced `LOG_LEVEL` configuration for backend logging control.
- Migrated Mini App to Next.js with enhanced loading states and styling improvements.
2026-03-03 18:55:33 +03:00
378daad503 chore: update CI workflow to include Node.js setup and Next.js build process
All checks were successful
CI / lint-and-test (push) Successful in 1m5s
- Added steps to set up Node.js version 20 in the CI workflow.
- Included commands to build and test the Next.js web application, ensuring integration with the existing Python-based project.
2026-03-03 18:43:45 +03:00
54f85a8f14 refactor: update DayDetail component and tests for clarity and functionality
Some checks failed
CI / lint-and-test (push) Failing after 28s
- Revised unit tests for DayDetailContent to reflect changes in duty entry display, ensuring only time and name are shown without contact links.
- Updated styling in DayDetail component to enhance visual consistency with background color adjustments.
- Removed unused ContactLinks component from DayDetailContent to streamline the code and improve readability.
2026-03-03 18:29:34 +03:00
8bf92bd4a1 refactor: update DayIndicators component and tests for improved clarity and accuracy
- Revised comments in DayIndicators component and test files to better describe the functionality of colored segments and rounding behavior.
- Updated test descriptions to reflect changes in rendering logic for single and multiple segments.
- Adjusted class names in the component and tests to ensure correct application of rounded styles based on segment positions.
2026-03-03 18:19:52 +03:00
68a153e4a7 feat: implement app content readiness handling in page and components
- Added `appContentReady` state to manage visibility of app content once loading is complete.
- Updated `useEffect` hooks in `CurrentDutyView` and `CalendarPage` to signal when content is ready, enhancing user experience by hiding native loading indicators.
- Refactored `Home` component to conditionally render content based on `appContentReady`, ensuring a smoother transition for users.
- Enhanced app store to include `setAppContentReady` method for state management.
2026-03-03 18:11:02 +03:00
cac06f22fa feat: add controlled flip functionality to DutyTimelineCard component
- Introduced `isFlipped` and `onFlipChange` props to `DutyTimelineCard` for controlled flipping behavior.
- Updated `DutyList` to manage the flipped state of duty cards, allowing only one card to be flipped at a time.
- Enhanced user interaction by implementing dedicated functions for flipping the card to contacts and back, improving usability.
2026-03-03 18:00:36 +03:00
87e8417675 refactor: improve code readability and structure in various components
- Refactored the `mini_app_short_name` assignment in `config.py` for better clarity.
- Enhanced the `app_config_js` function in `app.py` to improve formatting of the JavaScript response body.
- Added per-chat locks in `group_duty_pin.py` to prevent concurrent refreshes, improving message handling.
- Updated `_schedule_next_update` to include optional jitter for scheduling, enhancing performance during high-load scenarios.
- Cleaned up test files by removing unused imports and improving test descriptions for clarity.
2026-03-03 17:52:23 +03:00
37218a436a feat: add loopback host configuration for health checks
- Introduced LOOPBACK_HTTP_HOSTS in config.py to define valid loopback addresses for health-check URL and MINI_APP_SKIP_AUTH safety.
- Updated run.py to utilize LOOPBACK_HTTP_HOSTS for determining the host in health check and authentication logic.
- Enhanced test_app.py to skip tests if the required webapp output directory is not built, improving test reliability.
2026-03-03 17:47:39 +03:00
50d734e192 feat: update loading state handling in DutyList component
- Replaced the loading skeleton with a compact loading placeholder to improve user experience when data is not yet loaded for the month.
- Enhanced the rendering logic to ensure the loading state is visually distinct and does not display the skeleton when data is being fetched.
- Updated related tests to verify the new loading behavior and ensure accurate feedback during data fetching.
2026-03-03 17:42:03 +03:00
edf0186682 feat: enhance duty timeline styling for improved visibility
- Added horizontal stripe and vertical tick indicators for today's date in the duty timeline, enhancing visual distinction.
- Updated current duty card styling to ensure the left stripe matches the "Today" label, improving consistency in the user interface.
2026-03-03 17:36:05 +03:00
6e2188787e feat: implement pending month handling in calendar components
- Introduced a new `pendingMonth` state in the app store to manage month transitions without clearing current data, enhancing user experience during month navigation.
- Updated `useMonthData` hook to load data for the `pendingMonth` when set, preventing empty-frame flicker and ensuring smooth month switching.
- Modified `CalendarPage` and `CalendarGrid` components to utilize the new `pendingMonth` state, improving the rendering logic during month changes.
- Enhanced `DutyList` to display a loading skeleton while data is being fetched, providing better feedback to users.
- Updated relevant tests to cover the new loading behavior and state management for month transitions.
2026-03-03 17:20:23 +03:00
fd527917e0 refactor: simplify DutyTimelineCard component by removing TooltipProvider and Tooltip
- Removed TooltipProvider and Tooltip components from DutyTimelineCard, streamlining the button interaction for displaying contact information.
- Updated the button implementation to directly handle the click event, enhancing code clarity and reducing complexity.
2026-03-03 16:28:07 +03:00
95c9e23c33 refactor: simplify CalendarPage and CalendarHeader components by removing unused props
- Removed the handleGoToToday function and its associated prop from CalendarPage, streamlining the component's logic.
- Eliminated the isLoading and onRefresh props from CalendarHeader, enhancing clarity and reducing complexity.
- Updated internationalization messages to remove references to "Today" and "Refresh," aligning with the component changes.
2026-03-03 16:23:49 +03:00
95f65141e1 fix: update loading state handling in CalendarPage and DutyList components
- Set isLoading to false in CalendarPage to prevent unnecessary loading indicators.
- Removed the loading spinner from CalendarHeader and adjusted rendering logic in DutyList to show a placeholder when data is not yet loaded for the month.
- Enhanced tests in DutyList to verify behavior when data is empty and when data is loading, ensuring accurate user feedback during data fetching.
2026-03-03 16:21:27 +03:00
3b68e29d7b feat: enhance CalendarPage and DutyList components for improved loading state handling
- Removed the loading state placeholder from CalendarPage, directly rendering the CalendarGrid component.
- Updated DutyList to display a loading message when data is being fetched, enhancing user experience during data loading.
- Introduced a new dataForMonthKey in the app store to manage month-specific data more effectively.
- Refactored useMonthData hook to reset duties and calendarEvents when a new month is detected, ensuring accurate data representation.
- Added tests to verify the new loading state behavior in both components.
2026-03-03 16:17:24 +03:00
16bf1a1043 feat: migrate to Next.js for Mini App and enhance project structure
- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability.
- Updated the `.gitignore` to exclude Next.js build artifacts and node modules.
- Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack.
- Enhanced Dockerfile to support the new build process for the Next.js application.
- Updated CI workflow to build and test the Next.js application.
- Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking.
- Refactored frontend testing setup to accommodate the new structure and testing framework.
- Removed legacy webapp files and dependencies to streamline the project.
2026-03-03 16:04:08 +03:00
2de5c1cb81 fix: update current duty display text for improved clarity
Some checks failed
CI / lint-and-test (push) Failing after 27s
- Changed the displayed text for current duty from "Текущее дежурство" to "Сейчас дежурит" to enhance user understanding.
- This update aligns with the recent efforts to unify language handling across the application.
2026-03-03 00:12:04 +03:00
70b9050cb7 feat: improve error handling and logging in the web application
- Updated the error handling in `index.html` to include a retry button for failed app loads, enhancing user experience.
- Added session storage management to prevent repeated reloads on errors.
- Enhanced the `.gitignore` file to include log files, improving project cleanliness.
- Included error logging in `main.js` to ensure better tracking of issues during app initialization.
2026-03-03 00:10:30 +03:00
7ffa727832 feat: enhance error handling and configuration validation
Some checks failed
CI / lint-and-test (push) Failing after 27s
- Added a global exception handler to log unhandled exceptions and return a generic 500 JSON response without exposing details to the client.
- Updated the configuration to validate the `DATABASE_URL` format, ensuring it starts with `sqlite://` or `postgresql://`, and log warnings for invalid formats.
- Introduced safe parsing for numeric environment variables (`HTTP_PORT`, `INIT_DATA_MAX_AGE_SECONDS`) with defaults on invalid values, including logging warnings for out-of-range values.
- Enhanced the duty schedule parser to enforce limits on the number of schedule rows and the length of full names and duty strings, raising appropriate errors when exceeded.
- Updated internationalization messages to include generic error responses for import failures and parsing issues, improving user experience.
- Added unit tests to verify the new error handling and configuration validation behaviors.
2026-03-02 23:36:03 +03:00
43386b15fa feat: add configurable logging level for backend and Mini App
- Introduced a new `LOG_LEVEL` configuration option in the `.env.example` file to allow users to set the logging level (DEBUG, INFO, WARNING, ERROR).
- Updated the `Settings` class to include the `log_level` attribute, normalizing its value to ensure valid logging levels are used.
- Modified the logging setup in `run.py` to utilize the configured log level, enhancing flexibility in log management.
- Enhanced the Mini App to include the logging level in the JavaScript configuration, allowing for consistent logging behavior across the application.
- Added a new `logger.js` module for frontend logging, implementing level-based filtering and console delegation.
- Included unit tests for the new logger functionality to ensure proper behavior and level handling.
2026-03-02 23:15:22 +03:00
67ba9826c7 feat: unify language handling across the application
- Updated the language configuration to use a single source of truth from `DEFAULT_LANGUAGE` for the bot, API, and Mini App, eliminating auto-detection from user settings.
- Refactored the `get_lang` function to always return `DEFAULT_LANGUAGE`, ensuring consistent language usage throughout the application.
- Modified the handling of language in various components, including API responses and UI elements, to reflect the new language management approach.
- Enhanced documentation and comments to clarify the changes in language handling.
- Added unit tests to verify the new language handling behavior and ensure coverage for the updated functionality.
2026-03-02 23:05:28 +03:00
54446d7b0f feat: enhance UI components and error handling
- Updated HTML structure for navigation buttons in the calendar, adding SVG icons for improved visual clarity.
- Introduced a new muted text style in CSS for better presentation of empty duty list messages.
- Enhanced calendar CSS for navigation buttons and day indicators, improving layout and responsiveness.
- Improved error handling in the UI by adding retry functionality to the error display, allowing users to retry actions directly from the error message.
- Updated internationalization messages to include a retry option for error handling.
- Added unit tests to verify the new error handling behavior and UI updates.
2026-03-02 20:21:33 +03:00
37d4226beb chore: update project documentation and configuration files
- Added AGENTS.md for AI agent documentation and maintainers, outlining project structure and conventions.
- Updated CONTRIBUTING.md to specify that all project documentation must be in English, including README and docstrings.
- Enhanced README.md to reference documentation guidelines and the new AGENTS.md file.
- Cleaned up .gitignore by removing unnecessary entries for cursor-related files.
- Introduced new .cursor rules for backend, frontend, project architecture, and testing to standardize development practices.
2026-03-02 20:02:20 +03:00
174 changed files with 21859 additions and 7201 deletions

150
.cursor/rules/backend.mdc Normal file
View File

@@ -0,0 +1,150 @@
---
description: Rules for working with the Python backend (duty_teller/)
globs:
- duty_teller/**
- alembic/**
- tests/**
---
# Backend — Python
## Package layout
```
duty_teller/
├── main.py / run.py # Entry point: bot + uvicorn
├── config.py # Settings from env vars (python-dotenv)
├── cache.py # TTL caches with pattern invalidation
├── api/ # FastAPI app, routes, auth, ICS endpoints
│ ├── app.py # FastAPI app creation, route registration, static mount
│ ├── dependencies.py # get_db_session, require_miniapp_username, get_validated_dates
│ ├── telegram_auth.py # initData HMAC validation
│ ├── calendar_ics.py # External ICS fetch & parse
│ └── personal_calendar_ics.py
├── db/ # Database layer
│ ├── models.py # SQLAlchemy ORM models (Base)
│ ├── repository.py # CRUD functions (receive Session)
│ ├── schemas.py # Pydantic response schemas
│ └── session.py # session_scope, get_engine, get_session
├── handlers/ # Telegram bot command/message handlers
│ ├── commands.py # /start, /help, /set_phone, /calendar_link, /set_role
│ ├── common.py # is_admin_async, invalidate_is_admin_cache
│ ├── errors.py # Global error handler
│ ├── group_duty_pin.py # Pinned duty message in group chats
│ └── import_duty_schedule.py
├── i18n/ # Translations
│ ├── core.py # get_lang, t()
│ ├── lang.py # normalize_lang
│ └── messages.py # MESSAGES dict (ru/en)
├── importers/ # File parsers
│ └── duty_schedule.py # parse_duty_schedule
├── services/ # Business logic (receives Session)
│ ├── import_service.py # run_import
│ └── group_duty_pin_service.py
└── utils/ # Shared helpers
├── dates.py
├── handover.py
├── http_client.py
└── user.py
```
## Imports
- Use absolute imports from the `duty_teller` package: `from duty_teller.db.repository import get_or_create_user`.
- Never import handler modules from services or repository — dependency flows
downward: handlers → services → repository → models.
## DB access pattern
Handlers are async; SQLAlchemy sessions are synchronous. Two patterns:
### In bot handlers — `session_scope` + `run_in_executor`
```python
def do_work():
with session_scope(config.DATABASE_URL) as session:
# synchronous DB code here
...
await asyncio.get_running_loop().run_in_executor(None, do_work)
```
### In FastAPI endpoints — dependency injection
```python
@router.get("/api/duties")
def get_duties(session: Session = Depends(get_db_session)):
...
```
`get_db_session` yields a `session_scope` context — FastAPI closes it after the request.
## Handler patterns
### Admin-only commands
```python
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
```
`is_admin_async` is cached for 60 s and invalidated on role changes.
### i18n in handlers
```python
lang = get_lang(update.effective_user)
text = t(lang, "start.greeting")
```
`t(lang, key, **kwargs)` substitutes `{placeholders}` and falls back to English → raw key.
### Error handling
The global `error_handler` (registered via `app.add_error_handler`) logs the
exception and sends a generic localized error reply.
## Service layer
- Services receive a `Session` — they never open their own.
- Services call repository functions, never handler code.
- After mutations that affect cached data, call `invalidate_duty_related_caches()`.
## Cache invalidation
Three TTL caches in `cache.py`:
| Cache | TTL | Invalidation trigger |
|-------|-----|---------------------|
| `ics_calendar_cache` | 600 s | After duty import |
| `duty_pin_cache` | 90 s | After duty import |
| `is_admin_cache` | 60 s | After `set_user_role` |
- `invalidate_duty_related_caches()` clears ICS and pin caches (call after any duty mutation).
- `invalidate_is_admin_cache(telegram_user_id)` clears a single admin entry.
- Pattern invalidation: `cache.invalidate_pattern(key_prefix_tuple)`.
## Alembic migrations
- Config in `pyproject.toml` under `[tool.alembic]`, script location: `alembic/`.
- **Naming convention:** `NNN_description.py` — sequential zero-padded number (`001`, `002`, …).
- Revision IDs are the string number: `revision = "008"`, `down_revision = "007"`.
- Run: `alembic -c pyproject.toml upgrade head` / `downgrade -1`.
- `entrypoint.sh` runs `alembic upgrade head` before starting the app in Docker.
## Configuration
- All config comes from environment variables, loaded with `python-dotenv`.
- `duty_teller/config.py` exposes module-level constants (`BOT_TOKEN`, `DATABASE_URL`, etc.)
built from `Settings.from_env()`.
- Never hardcode secrets or URLs — always use `config.*` constants.
- Key variables: `BOT_TOKEN`, `DATABASE_URL`, `MINI_APP_BASE_URL`, `HTTP_HOST`, `HTTP_PORT`,
`ADMIN_USERNAMES`, `ALLOWED_USERNAMES`, `DUTY_DISPLAY_TZ`, `DEFAULT_LANGUAGE`.
## Code style
- Formatter: Black (line-length 120).
- Linter: Ruff (`ruff check duty_teller tests`).
- Follow PEP 8 and Google Python Style Guide for docstrings.
- Max line length: 120 characters.

View File

@@ -0,0 +1,53 @@
---
description: Rules for working with the Telegram Mini App frontend (webapp-next/)
globs:
- webapp-next/**
---
# Frontend — Telegram Mini App (Next.js)
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
## Stack
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
- **TypeScript**
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
## Structure
| Area | Location |
|------|----------|
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
| Providers | `src/components/providers/TelegramProvider.tsx` |
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
| Contact links | `src/components/contact/ContactLinks.tsx` |
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
| Store | `src/store/app-store.ts` |
| Types | `src/types/index.ts` |
## Conventions
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
## Testing
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
Consider these rules when changing the Mini App or adding frontend features.

113
.cursor/rules/project.mdc Normal file
View File

@@ -0,0 +1,113 @@
---
description: Overall project architecture and context for duty-teller
globs:
- "**"
---
# Project — duty-teller
A Telegram bot for team duty shift calendar management and group reminders,
with a Telegram Mini App (webapp) for calendar visualization.
## Architecture
```
┌──────────────┐ polling ┌──────────────────────┐
│ Telegram │◄────────────►│ python-telegram-bot │
│ Bot API │ │ (handlers/) │
└──────────────┘ └──────────┬───────────┘
┌──────────────┐ HTTP ┌──────────▼───────────┐
│ Telegram │◄────────────►│ FastAPI (api/) │
│ Mini App │ initData │ + static webapp │
│ (webapp-next/) │ auth └──────────┬───────────┘
└──────────────┘ │
┌──────────▼───────────┐
│ SQLite + SQLAlchemy │
│ (db/) │
└──────────────────────┘
```
- **Bot:** python-telegram-bot v22, async polling mode.
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
- **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
## Key packages
| Package | Role |
|---------|------|
| `duty_teller/handlers/` | Telegram bot command and message handlers |
| `duty_teller/api/` | FastAPI app, REST endpoints, Telegram initData auth, ICS feeds |
| `duty_teller/db/` | ORM models, repository (CRUD), session management, Pydantic schemas |
| `duty_teller/services/` | Business logic (import, duty pin) — receives Session |
| `duty_teller/importers/` | Duty schedule file parsers |
| `duty_teller/i18n/` | Bilingual translations (ru/en), `t()` function |
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
| `duty_teller/config.py` | Environment-based configuration |
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
## API endpoints
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/health` | None | Health check |
| GET | `/api/duties` | initData | Duties for date range |
| GET | `/api/calendar-events` | initData | External calendar events |
| GET | `/api/calendar/ical/team/{token}.ics` | Token | Team ICS feed |
| GET | `/api/calendar/ical/{token}.ics` | Token | Personal ICS feed |
| GET | `/app` | None | Static Mini App (HTML/JS/CSS) |
## Deployment
- **Docker:** Multi-stage build (`Dockerfile`), `docker-compose.prod.yml` for production.
- **Entrypoint:** `entrypoint.sh` runs `alembic -c pyproject.toml upgrade head`, then
starts `python main.py` as `botuser`.
- **Data:** SQLite DB persisted via Docker volume at `/app/data`.
- **Health:** `curl -f http://localhost:8080/health` every 30 s.
## CI/CD (Gitea Actions)
### Lint & test (`.gitea/workflows/ci.yml`)
Triggered on push/PR to `main` and `develop`:
1. **Ruff:** `ruff check duty_teller tests`
2. **Pytest:** `pytest tests/ -v` (80% coverage gate)
3. **Bandit:** `bandit -r duty_teller -ll` (security scan)
### Docker build & release (`.gitea/workflows/docker-build.yml`)
Triggered on `v*` tags:
1. Build and push image to Gitea registry.
2. Create release with auto-generated notes.
## Key environment variables
| Variable | Default | Required | Purpose |
|----------|---------|----------|---------|
| `BOT_TOKEN` | — | Yes | Telegram bot API token |
| `DATABASE_URL` | `sqlite:///data/duty_teller.db` | No | SQLAlchemy database URL |
| `MINI_APP_BASE_URL` | — | Yes (prod) | Public URL for the Mini App |
| `HTTP_HOST` | `127.0.0.1` | No | FastAPI bind host |
| `HTTP_PORT` | `8080` | No | FastAPI bind port |
| `ADMIN_USERNAMES` | — | No | Comma-separated admin Telegram usernames |
| `ALLOWED_USERNAMES` | — | No | Comma-separated allowed usernames |
| `DUTY_DISPLAY_TZ` | `Europe/Moscow` | No | Timezone for duty display |
| `DEFAULT_LANGUAGE` | `en` | No | Default UI language (`en` or `ru`) |
| `EXTERNAL_CALENDAR_ICS_URL` | — | No | External ICS URL for holidays |
| `CORS_ORIGINS` | `*` | No | Comma-separated CORS origins |
## Languages
- **Backend:** Python 3.12+
- **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
- **i18n:** Russian (default) and English
## Version control
- Git with Gitea Flow branching strategy.
- Conventional Commits for commit messages.
- PRs reviewed via Gitea Pull Requests.

83
.cursor/rules/testing.mdc Normal file
View File

@@ -0,0 +1,83 @@
---
description: Rules for writing and running tests
globs:
- tests/**
- webapp-next/src/**/*.test.{ts,tsx}
---
# Testing
## Python tests (pytest)
### Configuration
- Config in `pyproject.toml` under `[tool.pytest.ini_options]`.
- `asyncio_mode = "auto"` — async test functions are detected automatically, no decorator needed.
- Coverage: `--cov=duty_teller --cov-fail-under=80`.
- Run: `pytest tests/ -v` from project root.
### File layout
```
tests/
├── conftest.py # Shared fixtures (in-memory DB, sessions, bot app)
├── helpers.py # Test helper functions
└── test_*.py # Test modules
```
### Key fixtures (conftest.py)
- `conftest.py` sets `BOT_TOKEN=test-token-for-pytest` if the env var is missing,
so tests run without a real token.
- Database fixtures use in-memory SQLite for isolation.
### Writing Python tests
- File naming: `tests/test_<module>.py` (e.g. `tests/test_import_service.py`).
- Function naming: `test_<what>_<scenario>` or `test_<what>_<expected_result>`.
- Use `session_scope` with the test database URL for DB tests.
- Async tests: just use `async def test_...` — `asyncio_mode = "auto"` handles it.
- Mock external dependencies (Telegram API, HTTP calls) with `unittest.mock` or `pytest-mock`.
### Example structure
```python
import pytest
from duty_teller.db.session import session_scope
def test_get_or_create_user_creates_new(test_db_url):
with session_scope(test_db_url) as session:
user = get_or_create_user(session, telegram_user_id=123, full_name="Test")
assert user.telegram_user_id == 123
```
## Frontend tests (Vitest + React Testing Library)
### Configuration
- Config: `webapp-next/vitest.config.ts`.
- Environment: jsdom; React Testing Library for components.
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
- Setup: `webapp-next/src/test/setup.ts`.
- Run: `cd webapp-next && npm test` (or `npm run test`).
### Writing frontend tests
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
- Mock Telegram SDK and API fetch where necessary.
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
### Example structure
```ts
import { describe, it, expect } from "vitest";
import { localDateString } from "./date-utils";
describe("localDateString", () => {
it("formats date as YYYY-MM-DD", () => {
const d = new Date(2025, 0, 15);
expect(localDateString(d)).toBe("2025-01-15");
});
});
```

5
.cursor/worktrees.json Normal file
View File

@@ -0,0 +1,5 @@
{
"setup-worktree": [
"npm install"
]
}

View File

@@ -26,7 +26,10 @@ ADMIN_USERNAMES=admin1,admin2
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
# DUTY_PIN_NOTIFY=1
# Default UI language when user language is unknown: en or ru (default: en).
# Log level for backend and Mini App console logs: DEBUG, INFO, WARNING, ERROR. Default: INFO.
# LOG_LEVEL=INFO
# Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection.
# DEFAULT_LANGUAGE=en
# Reject Telegram initData older than this (seconds). 0 = do not check (default).

View File

@@ -26,6 +26,15 @@ jobs:
run: |
pip install ruff bandit
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: "20"
- name: Webapp (Next.js) build and test
run: |
cd webapp-next && npm ci && npm test && npm run build
- name: Lint with Ruff
run: |
ruff check duty_teller tests
@@ -39,14 +48,3 @@ 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

11
.gitignore vendored
View File

@@ -7,11 +7,18 @@ venv/
*.pyo
data/
*.db
.cursor/
.cursorrules/
# Test and coverage artifacts
.coverage
htmlcov/
.pytest_cache/
*.cover
*.plan.md
# Logs
*.log
# Next.js webapp
webapp-next/out/
webapp-next/node_modules/
webapp-next/.next/

51
AGENTS.md Normal file
View File

@@ -0,0 +1,51 @@
# Duty Teller — AI agent documentation
This file is for AI assistants (e.g. Cursor) and maintainers. All project documentation and docstrings must be in **English**. User-facing UI strings remain localized (ru/en) in `duty_teller/i18n/`.
## Project summary
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
## Key entry points
- **CLI / process:** `main.py` or, after `pip install -e .`, the `duty-teller` console command. Both delegate to `duty_teller.run.main()`.
- **Application setup:** `duty_teller/run.py` — builds the Telegram `Application`, registers handlers via `register_handlers(app)`, runs polling and FastAPI in a thread, calls `config.require_bot_token()` so the app exits clearly if `BOT_TOKEN` is missing.
- **HTTP API:** `duty_teller/api/app.py` — FastAPI app, route registration, static webapp mounted at `/app`.
## Where to change what
| Area | Location |
|------|----------|
| Telegram handlers | `duty_teller/handlers/` |
| REST API | `duty_teller/api/` |
| Business logic | `duty_teller/services/` |
| Database (models, repository, schemas) | `duty_teller/db/` |
| Translations (ru/en) | `duty_teller/i18n/` |
| Duty-schedule parser | `duty_teller/importers/` |
| Config (env vars) | `duty_teller/config.py` |
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
## Running and testing
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
- **Lint:** `ruff check duty_teller tests`
- **Security:** `bandit -r duty_teller -ll`
## Documentation
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
- Configuration reference: [docs/configuration.md](docs/configuration.md).
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
Docstrings and code comments must be in English (Google-style docstrings). UI strings are the only exception; they live in `duty_teller/i18n/`.
## Conventions
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`).
- **Branches:** Gitea Flow; changes via Pull Request.
- **Testing:** pytest, 80% coverage target; unit and integration tests.
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.

View File

@@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.0.0] - 2026-03-03
### Added
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning.
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent.
- **Command `/refresh_pin`**: in a group, immediately refresh the pinned duty message (send new message, unpin old, pin new).
- **Role-based access and `/set_role`**: Miniapp and admin access are determined by roles stored in the database (`roles` table, `users.role_id`). Roles: `user` (miniapp access), `admin` (miniapp + `/import_duty_schedule`, `/set_role`). Admins assign roles with `/set_role @username user|admin` (or reply to a message with `/set_role user|admin`). `ALLOWED_USERNAMES` and `ALLOWED_PHONES` are no longer used for access (kept for reference).
- **Command `/calendar_link`**: in private chat, send the user their personal ICS subscription URL (and team calendar URL) for calendar apps.
- **Config `MINI_APP_SHORT_NAME`**: when set, the pinned duty message "View contacts" button uses a direct Mini App link (`https://t.me/BotName/ShortName?startapp=duty`) so the app opens on the current-duty view.
- **Config `LOG_LEVEL`**: control backend logging and the Miniapp console logger (`window.__DT_LOG_LEVEL`); one of `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`).
- **Mini App**: migrated to Next.js (TypeScript, Tailwind, shadcn/ui) with static export; improved loading states, duty timeline styling, and content readiness handling; configurable loopback host for health checks.
### Changed
@@ -36,4 +44,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Input validation and initData hash verification for Miniapp access.
- Optional CORS and init_data_max_age; use env for secrets.
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->

View File

@@ -53,6 +53,15 @@
bandit -r duty_teller -ll
```
## Documentation
All project documentation must be in **English**. This includes:
- README, files in `docs/`, docstrings, and commit messages that touch documentation.
- Exception: user-facing UI strings are localized (Russian/English) in `duty_teller/i18n/` and are not considered project documentation.
Docstrings and code comments must be in English (Google-style docstrings). See [AGENTS.md](AGENTS.md) for AI/maintainer context.
## Commit messages
Use [Conventional Commits](https://www.conventionalcommits.org/), e.g.:

View File

@@ -1,14 +1,22 @@
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
# Multi-stage: webapp build (Next.js), Python builder, runtime.
# Single image for both dev and prod; Compose files differentiate behavior.
# --- Stage 1: builder (dependencies only) ---
# --- Stage 1: webapp build (Next.js static export) ---
FROM node:20-slim AS webapp-builder
WORKDIR /webapp
COPY webapp-next/package.json webapp-next/package-lock.json* ./
RUN npm ci
COPY webapp-next/ ./
RUN npm run build
# --- Stage 2: builder (Python dependencies only) ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY pyproject.toml ./
COPY duty_teller/ ./duty_teller/
RUN pip install --no-cache-dir .
# --- Stage 2: runtime (minimal final image) ---
# --- Stage 3: runtime (minimal final image) ---
FROM python:3.12-slim
WORKDIR /app
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
RUN chmod +x entrypoint.sh
COPY duty_teller/ ./duty_teller/
COPY alembic/ ./alembic/
COPY webapp/ ./webapp/
COPY --from=webapp-builder /webapp/out ./webapp-next/out
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
RUN adduser --disabled-password --gecos "" botuser \

View File

@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
- `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`.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
- `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.
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
- `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`.
- `webapp-next/` Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
- `tests/` Tests; `helpers.py` provides `make_init_data` for auth tests.
- `pyproject.toml` Installable package (`pip install -e .`).
@@ -151,6 +151,7 @@ Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config`
- **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.
- **Documentation:** Project documentation is in English; see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
## Logs and rotation

View File

@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
## Components
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.

View File

@@ -5,21 +5,23 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
| Variable | Type / format | Default | Description |
|----------|----------------|---------|-------------|
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Example: `sqlite:///data/duty_teller.db`. |
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
| **HTTP_PORT** | integer | `8080` | Port for the HTTP server (FastAPI + static webapp). |
| **HTTP_PORT** | integer (165535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
| **ADMIN_USERNAMES** | comma-separated list | *(empty)* | Telegram usernames treated as **admin fallback** when the user has **no role in the DB**. If a user has a role in the DB, only that role applies. Example: `admin1,admin2`. |
| **ALLOWED_PHONES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. |
| **ADMIN_PHONES** | comma-separated list | *(empty)* | Phones treated as **admin fallback** when the user has **no role in the DB** (user sets phone via `/set_phone`). Comparison uses digits only. Example: `+7 999 123-45-67`. |
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** |
| **INIT_DATA_MAX_AGE_SECONDS** | integer | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Example: `86400` for 24 hours. |
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. |
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** The process exits with an error if this is set and **HTTP_HOST** is not localhost (127.0.0.1). |
| **INIT_DATA_MAX_AGE_SECONDS** | integer (≥ 0) | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Invalid values fall back to 0. Example: `86400` for 24 hours. |
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. **In production**, set an explicit list (e.g. `https://your-domain.com`) instead of `*` to avoid allowing arbitrary origins. Example: `https://your-domain.com`. |
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. |
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. |
| **LOG_LEVEL** | `DEBUG`, `INFO`, `WARNING`, or `ERROR` | `INFO` | Logging level for the backend (Python `logging`) and for the Mini App console logger (`window.__DT_LOG_LEVEL`). Use `DEBUG` for troubleshooting; in production `INFO` or higher is recommended. |
## Roles and access

View File

@@ -11,3 +11,5 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
For quick start, setup, and API overview see the main [README](../README.md).
**For maintainers and AI:** Project documentation and docstrings must be in English; see [CONTRIBUTING.md](../CONTRIBUTING.md#documentation). [AGENTS.md](../AGENTS.md) in the repo root provides entry points, conventions, and where to change what.

View File

@@ -5,11 +5,10 @@ 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
from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session
@@ -43,6 +42,16 @@ def _is_valid_calendar_token(token: str) -> bool:
app = FastAPI(title="Duty Teller API")
@app.exception_handler(Exception)
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Log unhandled exceptions and return 500 without exposing details to the client."""
log.exception("Unhandled exception: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
@app.get("/health", summary="Health check")
def health() -> dict:
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
@@ -58,22 +67,102 @@ app.add_middleware(
)
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
class NoCacheStaticMiddleware:
"""
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
"""
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
def __init__(self, app, **kwargs):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
is_app_path = path == "/app" or path.startswith("/app/")
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
if is_app_path:
cache_control = (b"cache-control", b"no-store")
if b"cache-control" in header_names:
headers[header_names[b"cache-control"]] = cache_control
else:
headers.append(cache_control)
vary_val = b"Accept-Language"
if b"vary" in header_names:
idx = header_names[b"vary"]
existing = headers[idx][1]
tokens = [p.strip() for p in existing.split(b",")]
if vary_val not in tokens:
headers[idx] = (b"vary", existing + b", " + vary_val)
else:
headers.append((b"vary", vary_val))
message = {
"type": "http.response.start",
"status": message["status"],
"headers": headers,
}
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(NoCacheStaticMiddleware)
# Allowed values for config.js to prevent script injection.
_VALID_LANGS = frozenset({"en", "ru"})
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
if value in allowed:
return value
return default
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
def _safe_tz_string(value: str) -> str:
"""Return value if it matches safe timezone pattern, else empty string."""
if value and _TZ_SAFE_RE.match(value.strip()):
return value.strip()
return ""
@app.get(
"/app/config.js",
summary="Mini App config (language, log level, timezone)",
description=(
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
"Loaded before main.js."
),
)
def app_config_js() -> Response:
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
body = (
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
)
return Response(
content=body,
media_type="application/javascript; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@app.get(
"/api/duties",
response_model=list[DutyWithUser],
@@ -132,10 +221,10 @@ def get_team_calendar_ical(
) -> Response:
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
if not _is_valid_calendar_token(token):
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
cache_key = ("team_ics",)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
@@ -173,10 +262,10 @@ def get_personal_calendar_ical(
No Telegram auth; access is by secret token in the URL.
"""
if not _is_valid_calendar_token(token):
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
cache_key = ("personal_ics", user.id)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
@@ -194,6 +283,6 @@ def get_personal_calendar_ical(
)
webapp_path = config.PROJECT_ROOT / "webapp"
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
if webapp_path.is_dir():
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")

View File

@@ -1,7 +1,6 @@
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
import logging
import re
from typing import Annotated, Generator
from fastapi import Depends, Header, HTTPException, Query, Request
@@ -17,42 +16,18 @@ from duty_teller.db.repository import (
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
from duty_teller.db.session import session_scope
from duty_teller.i18n import t
from duty_teller.i18n.lang import normalize_lang
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
log = logging.getLogger(__name__)
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
def _parse_first_language_code(header: str | None) -> str | None:
"""Extract the first language code from Accept-Language header.
Args:
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
Returns:
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
"""
if not header or not header.strip():
return None
first = header.strip().split(",")[0].strip()
m = _ACCEPT_LANG_CODE_RE.match(first)
return m.group(1).lower() if m else None
def _lang_from_accept_language(header: str | None) -> str:
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
"""Return the application language: always config.DEFAULT_LANGUAGE.
Args:
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
Returns:
'ru' or 'en'.
The header argument is kept for backward compatibility but is ignored.
The whole deployment uses a single language from DEFAULT_LANGUAGE.
"""
code = _parse_first_language_code(header)
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
return config.DEFAULT_LANGUAGE
def _auth_error_detail(auth_reason: str, lang: str) -> str:
@@ -67,7 +42,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
try:
validate_date_range(from_date, to_date)
except DateRangeValidationError as e:
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
key_map = {
"bad_format": "dates.bad_format",
"from_after_to": "dates.from_after_to",
"range_too_large": "dates.range_too_large",
}
key = key_map.get(e.kind, "dates.bad_format")
raise HTTPException(status_code=400, detail=t(lang, key)) from e
except ValueError as e:
# Backward compatibility if something else raises ValueError.

View File

@@ -6,7 +6,7 @@ import json
import time
from urllib.parse import unquote
from duty_teller.i18n.lang import normalize_lang
import duty_teller.config as config
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
Returns:
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
"user_invalid", "no_user_id". lang is from user.language_code normalized
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
"ok", lang).
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
On success: (user.id, username or None, "ok", lang).
"""
lang = config.DEFAULT_LANGUAGE
if not init_data or not bot_token:
return (None, None, "empty", "en")
return (None, None, "empty", lang)
init_data = init_data.strip()
params = {}
for part in init_data.split("&"):
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
params[key] = value
hash_val = params.pop("hash", None)
if not hash_val:
return (None, None, "no_hash", "en")
return (None, None, "no_hash", lang)
data_pairs = sorted(params.items())
# Data-check string: key=value with URL-decoded values (per Telegram example)
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
digestmod=hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
return (None, None, "hash_mismatch", "en")
return (None, None, "hash_mismatch", lang)
if max_age_seconds is not None and max_age_seconds > 0:
auth_date_raw = params.get("auth_date")
if not auth_date_raw:
return (None, None, "auth_date_expired", "en")
return (None, None, "auth_date_expired", lang)
try:
auth_date = int(float(auth_date_raw))
except (ValueError, TypeError):
return (None, None, "auth_date_expired", "en")
return (None, None, "auth_date_expired", lang)
if time.time() - auth_date > max_age_seconds:
return (None, None, "auth_date_expired", "en")
return (None, None, "auth_date_expired", lang)
user_raw = params.get("user")
if not user_raw:
return (None, None, "no_user", "en")
return (None, None, "no_user", lang)
try:
user = json.loads(unquote(user_raw))
except (json.JSONDecodeError, TypeError):
return (None, None, "user_invalid", "en")
return (None, None, "user_invalid", lang)
if not isinstance(user, dict):
return (None, None, "user_invalid", "en")
lang = normalize_lang(user.get("language_code"))
return (None, None, "user_invalid", lang)
raw_id = user.get("id")
if raw_id is None:
return (None, None, "no_user_id", lang)

View File

@@ -1,9 +1,11 @@
"""Load configuration from environment (e.g. .env via python-dotenv).
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point
when running the bot.
when running the bot. Numeric env vars (HTTP_PORT, INIT_DATA_MAX_AGE_SECONDS) use
safe parsing with defaults on invalid values.
"""
import logging
import os
import re
from dataclasses import dataclass
@@ -15,6 +17,14 @@ from duty_teller.i18n.lang import normalize_lang
load_dotenv()
logger = logging.getLogger(__name__)
# Valid port range for HTTP_PORT.
HTTP_PORT_MIN, HTTP_PORT_MAX = 1, 65535
# Host values treated as loopback (for health-check URL and MINI_APP_SKIP_AUTH safety).
LOOPBACK_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1", "")
# Project root (parent of duty_teller package). Used for webapp path, etc.
PROJECT_ROOT = Path(__file__).resolve().parent.parent
@@ -46,6 +56,56 @@ def _parse_phone_list(raw: str) -> set[str]:
return result
def _normalize_log_level(raw: str) -> str:
"""Return a valid log level name (DEBUG, INFO, WARNING, ERROR); default INFO."""
level = (raw or "").strip().upper()
if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
return level
return "INFO"
def _parse_int_env(
name: str, default: int, min_val: int | None = None, max_val: int | None = None
) -> int:
"""Parse an integer from os.environ; use default on invalid or out-of-range. Log on fallback."""
raw = os.getenv(name)
if raw is None or raw == "":
return default
try:
value = int(raw.strip())
except ValueError:
logger.warning(
"Invalid %s=%r (expected integer); using default %s",
name,
raw,
default,
)
return default
if min_val is not None and value < min_val:
logger.warning(
"%s=%s is below minimum %s; using %s", name, value, min_val, min_val
)
return min_val
if max_val is not None and value > max_val:
logger.warning(
"%s=%s is above maximum %s; using %s", name, value, max_val, max_val
)
return max_val
return value
def _validate_database_url(url: str) -> bool:
"""Return True if URL looks like a supported SQLAlchemy URL (sqlite or postgres)."""
if not url or not isinstance(url, str):
return False
u = url.strip().split("?", 1)[0].lower()
return (
u.startswith("sqlite://")
or u.startswith("postgresql://")
or u.startswith("postgres://")
)
@dataclass(frozen=True)
class Settings:
"""Injectable settings built from environment. Used in tests or when env is overridden."""
@@ -54,6 +114,7 @@ class Settings:
database_url: str
bot_username: str
mini_app_base_url: str
mini_app_short_name: str
http_host: str
http_port: int
allowed_usernames: set[str]
@@ -67,6 +128,7 @@ class Settings:
duty_display_tz: str
default_language: str
duty_pin_notify: bool
log_level: str
@classmethod
def from_env(cls) -> "Settings":
@@ -95,20 +157,33 @@ 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()
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
if not _validate_database_url(database_url):
logger.warning(
"DATABASE_URL does not look like a supported URL (sqlite:// or postgresql://); "
"DB connection may fail."
)
http_port = _parse_int_env(
"HTTP_PORT", 8080, min_val=HTTP_PORT_MIN, max_val=HTTP_PORT_MAX
)
init_data_max_age = _parse_int_env("INIT_DATA_MAX_AGE_SECONDS", 0, min_val=0)
return cls(
bot_token=bot_token,
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
database_url=database_url,
bot_username=bot_username,
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "")
.strip()
.strip("/"),
http_host=http_host,
http_port=int(os.getenv("HTTP_PORT", "8080")),
http_port=http_port,
allowed_usernames=allowed,
admin_usernames=admin,
allowed_phones=allowed_phones,
admin_phones=admin_phones,
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
in ("1", "true", "yes"),
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
init_data_max_age_seconds=init_data_max_age,
cors_origins=cors,
external_calendar_ics_url=os.getenv(
"EXTERNAL_CALENDAR_ICS_URL", ""
@@ -118,6 +193,7 @@ class Settings:
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
not in ("0", "false", "no"),
log_level=_normalize_log_level(os.getenv("LOG_LEVEL", "INFO")),
)
@@ -128,6 +204,7 @@ BOT_TOKEN = _settings.bot_token
DATABASE_URL = _settings.database_url
BOT_USERNAME = _settings.bot_username
MINI_APP_BASE_URL = _settings.mini_app_base_url
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
HTTP_HOST = _settings.http_host
HTTP_PORT = _settings.http_port
ALLOWED_USERNAMES = _settings.allowed_usernames
@@ -141,6 +218,8 @@ EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url
DUTY_DISPLAY_TZ = _settings.duty_display_tz
DEFAULT_LANGUAGE = _settings.default_language
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
LOG_LEVEL = getattr(logging, _settings.log_level.upper(), logging.INFO)
LOG_LEVEL_STR = _settings.log_level
def is_admin(username: str) -> bool:

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
import duty_teller.config as config
from duty_teller.db.schemas import DUTY_EVENT_TYPES
from duty_teller.db.models import (
User,
Duty,
@@ -201,14 +202,19 @@ def get_or_create_user(
return user
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
def get_or_create_user_by_full_name(
session: Session, full_name: str, *, commit: bool = True
) -> User:
"""Find user by exact full_name or create one (for duty-schedule import).
New users have telegram_user_id=None and name_manually_edited=True.
When commit=False, caller is responsible for committing (e.g. single commit
per import in run_import).
Args:
session: DB session.
full_name: Exact full name to match or set.
commit: If True, commit immediately. If False, caller commits.
Returns:
User instance (existing or newly created).
@@ -225,8 +231,11 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
name_manually_edited=True,
)
session.add(user)
session.commit()
session.refresh(user)
if commit:
session.commit()
session.refresh(user)
else:
session.flush() # Assign id so caller can use user.id before commit
return user
@@ -447,11 +456,13 @@ def insert_duty(
user_id: User id.
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
end_at: End time UTC, ISO 8601 with Z.
event_type: One of "duty", "unavailable", "vacation". Default "duty".
event_type: One of "duty", "unavailable", "vacation". Invalid values are stored as "duty".
Returns:
Created Duty instance.
"""
if event_type not in DUTY_EVENT_TYPES:
event_type = "duty"
duty = Duty(
user_id=user_id,
start_at=start_at,

View File

@@ -67,7 +67,8 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
phone = " ".join(args).strip() if args else None
telegram_user_id = update.effective_user.id
def do_set_phone() -> str | None:
def do_set_phone() -> tuple[str, str | None]:
"""Returns (status, display_phone). status is 'error'|'saved'|'cleared'. display_phone for 'saved'."""
with session_scope(config.DATABASE_URL) as session:
full_name = build_full_name(
update.effective_user.first_name, update.effective_user.last_name
@@ -82,16 +83,20 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
)
user = set_user_phone(session, telegram_user_id, phone or None)
if user is None:
return "error"
return ("error", None)
if phone:
return "saved"
return "cleared"
return ("saved", user.phone or config.normalize_phone(phone))
return ("cleared", None)
result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)
result, display_phone = await asyncio.get_running_loop().run_in_executor(
None, do_set_phone
)
if result == "error":
await update.message.reply_text(t(lang, "set_phone.error"))
elif result == "saved":
await update.message.reply_text(t(lang, "set_phone.saved", phone=phone or ""))
await update.message.reply_text(
t(lang, "set_phone.saved", phone=display_phone or "")
)
else:
await update.message.reply_text(t(lang, "set_phone.cleared"))

View File

@@ -2,7 +2,8 @@
import asyncio
import logging
from datetime import datetime, timezone
import random
from datetime import datetime, timedelta, timezone
from typing import Literal
import duty_teller.config as config
@@ -29,6 +30,10 @@ from duty_teller.services.group_duty_pin_service import (
logger = logging.getLogger(__name__)
# Per-chat locks to prevent concurrent refresh for the same chat (avoids duplicate messages).
_refresh_locks: dict[int, asyncio.Lock] = {}
_lock_for_refresh_locks = asyncio.Lock()
JOB_NAME_PREFIX = "duty_pin_"
RETRY_WHEN_NO_DUTY_MINUTES = 15
@@ -87,10 +92,19 @@ def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
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.
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
with start_param=duty (current duty view). Otherwise the link is to the bot with
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
start_param in some clients).
"""
if not config.BOT_USERNAME:
return None
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
short = (config.MINI_APP_SHORT_NAME or "").strip().strip("/")
if short:
url = f"https://t.me/{config.BOT_USERNAME}/{short}?startapp=duty"
else:
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
button = InlineKeyboardButton(
text=t(lang, "pin_duty.view_contacts"),
url=url,
@@ -115,8 +129,12 @@ def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None
application,
chat_id: int,
when_utc: datetime | None,
jitter_seconds: float | None = None,
) -> None:
"""Schedule the next pin refresh job. Optional jitter spreads jobs when scheduling many chats."""
job_queue = application.job_queue
if job_queue is None:
logger.warning("Job queue not available, cannot schedule pin update")
@@ -127,8 +145,10 @@ async def _schedule_next_update(
if when_utc is not None:
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
delay = when_utc - now_utc
if jitter_seconds is not None and jitter_seconds > 0:
delay += timedelta(seconds=random.uniform(0, jitter_seconds))
if delay.total_seconds() < 1:
delay = 1
delay = timedelta(seconds=1)
job_queue.run_once(
update_group_pin,
when=delay,
@@ -137,8 +157,6 @@ async def _schedule_next_update(
)
logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc)
else:
from datetime import timedelta
job_queue.run_once(
update_group_pin,
when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES),
@@ -159,11 +177,13 @@ async def _refresh_pin_for_chat(
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".
Unpin is best-effort (e.g. if user already unpinned we still pin the new message and save state).
Per-chat lock prevents concurrent refresh for the same chat.
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 pin failed;
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
"""
loop = asyncio.get_running_loop()
@@ -196,42 +216,59 @@ async def _refresh_pin_for_chat(
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
return "no_message"
old_message_id = message_id
async with _lock_for_refresh_locks:
lock = _refresh_locks.setdefault(chat_id, asyncio.Lock())
try:
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
)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
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("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
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)
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"
async with lock:
try:
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,
)
await _schedule_next_update(context.application, chat_id, next_end)
return "failed"
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden) as e:
logger.debug(
"Unpin failed (e.g. no pinned message) chat_id=%s: %s", chat_id, e
)
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("Pin after refresh failed chat_id=%s: %s", chat_id, e)
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)
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"
finally:
async with _lock_for_refresh_locks:
_refresh_locks.pop(chat_id, None)
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -329,12 +366,15 @@ def _get_all_pin_chat_ids_sync() -> list[int]:
async def restore_group_pin_jobs(application) -> None:
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup)."""
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).
Uses jitter (060 s) per chat to avoid thundering herd when many groups share the same shift end.
"""
loop = asyncio.get_running_loop()
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
for chat_id in chat_ids:
await _schedule_next_update(application, chat_id, next_end)
await _schedule_next_update(application, chat_id, next_end, jitter_seconds=60.0)
logger.info("Restored %s group pin jobs", len(chat_ids))
@@ -398,6 +438,8 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
message_id=message_id,
disable_notification=True,
)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
await update.message.reply_text(t(lang, "pin_duty.pinned"))
except (BadRequest, Forbidden) as e:
logger.warning("pin_duty failed chat_id=%s: %s", chat_id, e)

View File

@@ -1,6 +1,7 @@
"""Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file."""
import asyncio
import logging
import duty_teller.config as config
from telegram import Update
@@ -16,6 +17,8 @@ from duty_teller.importers.duty_schedule import (
from duty_teller.services.import_service import run_import
from duty_teller.utils.handover import parse_handover_time
logger = logging.getLogger(__name__)
async def import_duty_schedule_cmd(
update: Update, context: ContextTypes.DEFAULT_TYPE
@@ -80,9 +83,10 @@ async def handle_duty_schedule_document(
try:
result = parse_duty_schedule(raw)
except DutyScheduleParseError as e:
logger.warning("Duty schedule parse error: %s", e, exc_info=True)
context.user_data.pop("awaiting_duty_schedule_file", None)
context.user_data.pop("handover_utc_time", None)
await update.message.reply_text(t(lang, "import.parse_error", error=str(e)))
await update.message.reply_text(t(lang, "import.parse_error_generic"))
return
def run_import_with_scope():
@@ -95,7 +99,8 @@ async def handle_duty_schedule_document(
None, run_import_with_scope
)
except Exception as e:
await update.message.reply_text(t(lang, "import.import_error", error=str(e)))
logger.exception("Import failed: %s", e)
await update.message.reply_text(t(lang, "import.import_error_generic"))
else:
total = num_duty + num_unavailable + num_vacation
unavailable_suffix = (

View File

@@ -1,9 +1,8 @@
"""get_lang and t(): language from Telegram user, translate by key with fallback to en."""
"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
from typing import TYPE_CHECKING
import duty_teller.config as config
from duty_teller.i18n.lang import normalize_lang
from duty_teller.i18n.messages import MESSAGES
if TYPE_CHECKING:
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
def get_lang(user: "User | None") -> str:
"""
Normalize Telegram user language to 'ru' or 'en'.
Uses normalize_lang for user.language_code; when user is None or has no
language_code, returns config.DEFAULT_LANGUAGE.
Return the application language: always config.DEFAULT_LANGUAGE.
The user argument is kept for backward compatibility but is ignored.
The whole deployment uses a single language from DEFAULT_LANGUAGE.
"""
if user is None or not getattr(user, "language_code", None):
return config.DEFAULT_LANGUAGE
return normalize_lang(user.language_code)
return config.DEFAULT_LANGUAGE
def t(lang: str, key: str, **kwargs: str) -> str:

View File

@@ -72,7 +72,9 @@ MESSAGES: dict[str, dict[str, str]] = {
"import.send_json": "Send the duty-schedule file (JSON).",
"import.need_json": "File must have .json extension.",
"import.parse_error": "File parse error: {error}",
"import.parse_error_generic": "The file could not be parsed. Check the format and try again.",
"import.import_error": "Import error: {error}",
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
"import.done": (
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
"({total} events total)."
@@ -88,6 +90,7 @@ 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",
"dates.range_too_large": "Date range is too large. Request a shorter period.",
"contact.show": "Contacts",
"contact.back": "Back",
"current_duty.title": "Current Duty",
@@ -160,7 +163,9 @@ MESSAGES: dict[str, dict[str, str]] = {
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
"import.need_json": "Нужен файл с расширением .json",
"import.parse_error": "Ошибка разбора файла: {error}",
"import.parse_error_generic": "Не удалось разобрать файл. Проверьте формат и попробуйте снова.",
"import.import_error": "Ошибка импорта: {error}",
"import.import_error_generic": "Импорт не выполнен. Попробуйте снова или обратитесь к администратору.",
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
"import.done_unavailable": ", {count} недоступностей",
"import.done_vacation": ", {count} отпусков",
@@ -171,6 +176,7 @@ MESSAGES: dict[str, dict[str, str]] = {
"api.access_denied": "Доступ запрещён",
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
"dates.from_after_to": "Дата from не должна быть позже to",
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
"contact.show": "Контакты",
"contact.back": "Назад",
"current_duty.title": "Текущее дежурство",

View File

@@ -9,6 +9,11 @@ DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
UNAVAILABLE_MARKER = "Н"
VACATION_MARKER = "О"
# Limits to avoid abuse and unreasonable input.
MAX_SCHEDULE_ROWS = 500
MAX_FULL_NAME_LENGTH = 200
MAX_DUTY_STRING_LENGTH = 10000
@dataclass
class DutyScheduleEntry:
@@ -69,10 +74,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
except ValueError as e:
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from e
# Reject dates outside current year ± 1.
today = date.today()
min_year = today.year - 1
max_year = today.year + 1
if not (min_year <= start_date.year <= max_year):
raise DutyScheduleParseError(
f"meta.start_date year must be between {min_year} and {max_year}"
)
schedule = data.get("schedule")
if not isinstance(schedule, list):
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
if len(schedule) > MAX_SCHEDULE_ROWS:
raise DutyScheduleParseError(
f"schedule has too many rows (max {MAX_SCHEDULE_ROWS})"
)
max_days = 0
entries: list[DutyScheduleEntry] = []
@@ -85,12 +104,20 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
full_name = name.strip()
if not full_name:
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
if len(full_name) > MAX_FULL_NAME_LENGTH:
raise DutyScheduleParseError(
f"schedule item 'name' must not exceed {MAX_FULL_NAME_LENGTH} characters"
)
duty_str = row.get("duty")
if duty_str is None:
duty_str = ""
if not isinstance(duty_str, str):
raise DutyScheduleParseError("schedule item 'duty' must be string")
if len(duty_str) > MAX_DUTY_STRING_LENGTH:
raise DutyScheduleParseError(
f"schedule item 'duty' must not exceed {MAX_DUTY_STRING_LENGTH} characters"
)
cells = [c.strip() for c in duty_str.split(";")]
max_days = max(max_days, len(cells))
@@ -120,4 +147,9 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
else:
end_date = start_date + timedelta(days=max_days - 1)
if not (min_year <= end_date.year <= max_year):
raise DutyScheduleParseError(
f"Computed end_date year must be between {min_year} and {max_year}"
)
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)

View File

@@ -3,7 +3,9 @@
import asyncio
import json
import logging
import sys
import threading
import time
import urllib.request
from telegram.ext import ApplicationBuilder
@@ -13,6 +15,9 @@ 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
# Seconds to wait for HTTP server to bind before health check.
_HTTP_STARTUP_WAIT_SEC = 3
async def _resolve_bot_username(application) -> None:
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
@@ -24,7 +29,7 @@ async def _resolve_bot_username(application) -> None:
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
level=config.LOG_LEVEL,
)
logger = logging.getLogger(__name__)
@@ -69,6 +74,25 @@ def _run_uvicorn(web_app, port: int) -> None:
loop.run_until_complete(server.serve())
def _wait_for_http_ready(port: int) -> bool:
"""Return True if /health responds successfully within _HTTP_STARTUP_WAIT_SEC."""
host = config.HTTP_HOST
if host not in config.LOOPBACK_HTTP_HOSTS:
host = "127.0.0.1"
url = f"http://{host}:{port}/health"
deadline = time.monotonic() + _HTTP_STARTUP_WAIT_SEC
while time.monotonic() < deadline:
try:
req = urllib.request.Request(url)
with safe_urlopen(req, timeout=2) as resp:
if resp.status == 200:
return True
except Exception as e:
logger.debug("Health check not ready yet: %s", e)
time.sleep(0.5)
return False
def main() -> None:
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
require_bot_token()
@@ -85,16 +109,30 @@ def main() -> None:
from duty_teller.api.app import app as web_app
t = threading.Thread(
target=_run_uvicorn,
args=(web_app, config.HTTP_PORT),
daemon=True,
)
t.start()
if config.MINI_APP_SKIP_AUTH:
logger.warning(
"MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev"
)
if config.HTTP_HOST not in config.LOOPBACK_HTTP_HOSTS:
print(
"ERROR: MINI_APP_SKIP_AUTH must not be used in production (non-localhost).",
file=sys.stderr,
)
sys.exit(1)
t = threading.Thread(
target=_run_uvicorn,
args=(web_app, config.HTTP_PORT),
daemon=False,
)
t.start()
if not _wait_for_http_ready(config.HTTP_PORT):
logger.error(
"HTTP server did not become ready on port %s within %s s; check port and permissions.",
config.HTTP_PORT,
_HTTP_STARTUP_WAIT_SEC,
)
sys.exit(1)
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
app.run_polling(allowed_updates=["message", "my_chat_member"])

View File

@@ -1,5 +1,6 @@
"""Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
import logging
from datetime import date, timedelta
from sqlalchemy.orm import Session
@@ -14,6 +15,8 @@ from duty_teller.db.repository import (
from duty_teller.importers.duty_schedule import DutyScheduleResult
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
logger = logging.getLogger(__name__)
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
@@ -53,16 +56,24 @@ def run_import(
Returns:
Tuple (num_users, num_duty, num_unavailable, num_vacation).
"""
logger.info(
"Import started: range %s..%s, %d entries",
result.start_date,
result.end_date,
len(result.entries),
)
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
# Batch: get all users by full_name, create missing (no commit until end)
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)
users_map[name] = get_or_create_user_by_full_name(
session, name, commit=False
)
# Delete range per user (no commit)
for entry in result.entries:
@@ -113,4 +124,11 @@ def run_import(
session.bulk_insert_mappings(Duty, duty_rows)
session.commit()
invalidate_duty_related_caches()
logger.info(
"Import done: %d users, %d duty, %d unavailable, %d vacation",
len(result.entries),
num_duty,
num_unavailable,
num_vacation,
)
return (len(result.entries), num_duty, num_unavailable, num_vacation)

View File

@@ -24,10 +24,17 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# Maximum allowed date range in days (e.g. 731 = 2 years).
MAX_DATE_RANGE_DAYS = 731
class DateRangeValidationError(ValueError):
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
def __init__(
self,
kind: Literal["bad_format", "from_after_to", "range_too_large"],
) -> None:
self.kind = kind
super().__init__(kind)
@@ -86,12 +93,20 @@ def parse_iso_date(s: str) -> date | None:
def validate_date_range(from_date: str, to_date: str) -> None:
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
"""Validate from_date and to_date are YYYY-MM-DD, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS.
Raises:
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to,
range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS.
"""
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
raise DateRangeValidationError("bad_format")
if from_date > to_date:
raise DateRangeValidationError("from_after_to")
try:
from_d = date.fromisoformat(from_date)
to_d = date.fromisoformat(to_date)
except ValueError:
raise DateRangeValidationError("bad_format") from None
if (to_d - from_d).days > MAX_DATE_RANGE_DAYS:
raise DateRangeValidationError("range_too_large")

View File

@@ -10,24 +10,29 @@ import duty_teller.config as config
class TestLangFromAcceptLanguage:
"""Tests for _lang_from_accept_language."""
"""Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE."""
def test_none_returns_default(self):
def test_always_returns_default_language(self):
"""Header is ignored; result is always config.DEFAULT_LANGUAGE."""
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE
def test_empty_string_returns_default(self):
assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
assert (
deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE
)
assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE
assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE
def test_ru_ru_returns_ru(self):
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
def test_returns_ru_when_default_language_is_ru(self):
with patch.object(config, "DEFAULT_LANGUAGE", "ru"):
assert deps._lang_from_accept_language("en-US") == "ru"
assert deps._lang_from_accept_language(None) == "ru"
def test_en_us_returns_en(self):
assert deps._lang_from_accept_language("en-US") == "en"
def test_invalid_fallback_to_en(self):
assert deps._lang_from_accept_language("zz") == "en"
assert deps._lang_from_accept_language("x") == "en"
def test_returns_en_when_default_language_is_en(self):
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
assert deps._lang_from_accept_language("ru-RU") == "en"
assert deps._lang_from_accept_language(None) == "en"
class TestAuthErrorDetail:

View File

@@ -23,6 +23,80 @@ def test_health(client):
assert r.json() == {"status": "ok"}
def test_unhandled_exception_returns_500_json(client):
"""Global exception handler returns 500 JSON without leaking exception details."""
from unittest.mock import MagicMock
from duty_teller.api.app import global_exception_handler
# Call the registered handler directly: it returns JSON and does not expose str(exc).
request = MagicMock()
exc = RuntimeError("internal failure")
response = global_exception_handler(request, exc)
assert response.status_code == 500
assert response.body.decode() == '{"detail":"Internal server error"}'
assert "internal failure" not in response.body.decode()
def test_health_has_vary_accept_language(client):
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
r = client.get("/health")
assert r.status_code == 200
assert "accept-language" in r.headers.get("vary", "").lower()
def test_app_static_has_no_store_and_vary(client):
"""Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
r = client.get("/app/")
if r.status_code != 200:
r = client.get("/app")
assert r.status_code == 200, (
"webapp static mount should serve index at /app or /app/"
)
assert r.headers.get("cache-control") == "no-store"
assert "accept-language" in r.headers.get("vary", "").lower()
def test_app_js_has_no_store(client):
"""JS and all static under /app get Cache-Control: no-store."""
webapp_out = config.PROJECT_ROOT / "webapp-next" / "out"
if not webapp_out.is_dir():
pytest.skip("webapp-next/out not built")
# Next.js static export serves JS under _next/static/chunks/<hash>.js
js_files = list(webapp_out.glob("_next/static/chunks/*.js"))
if not js_files:
pytest.skip("no JS chunks in webapp-next/out")
rel = js_files[0].relative_to(webapp_out)
r = client.get(f"/app/{rel.as_posix()}")
assert r.status_code == 200
assert r.headers.get("cache-control") == "no-store"
def test_app_config_js_returns_lang_from_default_language(client):
"""GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
r = client.get("/app/config.js")
assert r.status_code == 200
assert r.headers.get("content-type", "").startswith("application/javascript")
assert r.headers.get("cache-control") == "no-store"
body = r.text
assert "window.__DT_LANG" in body
assert config.DEFAULT_LANGUAGE in body
@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
def test_app_config_js_sanitizes_lang_and_log_level(client):
"""config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
r = client.get("/app/config.js")
assert r.status_code == 200
body = r.text
# Must be valid JS and not contain the raw malicious strings.
assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
assert "alert" not in body
assert "INJECT" not in body
assert "window.__DT_LOG_LEVEL" in body
def test_duties_invalid_date_format(client):
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
assert r.status_code == 400
@@ -37,6 +111,30 @@ def test_duties_from_after_to(client):
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
def test_duties_range_too_large_400(client):
"""Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
from datetime import date, timedelta
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
from_d = date(2020, 1, 1)
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
r = client.get(
"/api/duties",
params={"from": from_d.isoformat(), "to": to_d.isoformat()},
)
assert r.status_code == 400
detail = r.json()["detail"]
# EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
assert (
"range" in detail.lower()
or "short" in detail.lower()
or "короткий" in detail
or "большой" in detail
)
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_duties_403_without_init_data(client):
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
@@ -272,20 +370,21 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
def test_calendar_ical_team_404_invalid_token_format(client):
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
r = client.get("/api/calendar/ical/team/short.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
assert r.headers.get("content-type", "").startswith("application/json")
assert r.json() == {"detail": "Not found"}
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
mock_get_user.return_value = None
valid_format_token = "B" * 43
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
assert r.json() == {"detail": "Not found"}
mock_get_user.assert_called_once()
@@ -337,11 +436,10 @@ def test_calendar_ical_team_200_only_duty_and_description(
def test_calendar_ical_404_invalid_token_format(client):
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
# Token format must be base64url, 4050 chars; short or invalid chars → 404
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
r = client.get("/api/calendar/ical/short.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
assert r.json() == {"detail": "Not found"}
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
assert r2.status_code == 404
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
@@ -350,13 +448,12 @@ def test_calendar_ical_404_invalid_token_format(client):
@patch("duty_teller.api.app.get_user_by_calendar_token")
def test_calendar_ical_404_unknown_token(mock_get_user, client):
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
mock_get_user.return_value = None
# Use a token that passes format validation (base64url, 4050 chars)
valid_format_token = "A" * 43
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
assert r.status_code == 404
assert "not found" in r.text.lower()
assert r.json() == {"detail": "Not found"}
mock_get_user.assert_called_once()

View File

@@ -91,3 +91,29 @@ def test_require_bot_token_does_not_raise_when_set(monkeypatch):
"""require_bot_token() does nothing when BOT_TOKEN is set."""
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
config.require_bot_token()
def test_settings_from_env_invalid_http_port_uses_default(monkeypatch):
"""Invalid HTTP_PORT (non-numeric or out of range) yields default or clamped value."""
monkeypatch.delenv("HTTP_PORT", raising=False)
settings = config.Settings.from_env()
assert 1 <= settings.http_port <= 65535
monkeypatch.setenv("HTTP_PORT", "not-a-number")
settings = config.Settings.from_env()
assert settings.http_port == 8080
monkeypatch.setenv("HTTP_PORT", "0")
settings = config.Settings.from_env()
assert settings.http_port == 1
monkeypatch.setenv("HTTP_PORT", "99999")
settings = config.Settings.from_env()
assert settings.http_port == 65535
def test_settings_from_env_invalid_init_data_max_age_uses_default(monkeypatch):
"""Invalid INIT_DATA_MAX_AGE_SECONDS yields default 0."""
monkeypatch.setenv("INIT_DATA_MAX_AGE_SECONDS", "invalid")
settings = config.Settings.from_env()
assert settings.init_data_max_age_seconds == 0

View File

@@ -1,11 +1,14 @@
"""Tests for duty-schedule JSON parser."""
import json
from datetime import date
import pytest
from duty_teller.importers.duty_schedule import (
DUTY_MARKERS,
MAX_FULL_NAME_LENGTH,
MAX_SCHEDULE_ROWS,
UNAVAILABLE_MARKER,
VACATION_MARKER,
DutyScheduleParseError,
@@ -118,3 +121,38 @@ def test_unavailable_and_vacation_markers():
assert entry.unavailable_dates == [date(2026, 2, 1)]
assert entry.vacation_dates == [date(2026, 2, 2)]
assert entry.duty_dates == [date(2026, 2, 3)]
def test_parse_start_date_year_out_of_range():
"""start_date year must be current ± 1; otherwise DutyScheduleParseError."""
# Use a year far in the past/future so it fails regardless of test run date.
raw_future = b'{"meta": {"start_date": "2030-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
with pytest.raises(DutyScheduleParseError, match="year|2030"):
parse_duty_schedule(raw_future)
raw_past = b'{"meta": {"start_date": "2019-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
with pytest.raises(DutyScheduleParseError, match="year|2019"):
parse_duty_schedule(raw_past)
def test_parse_too_many_schedule_rows():
"""More than MAX_SCHEDULE_ROWS rows raises DutyScheduleParseError."""
rows = [{"name": f"User {i}", "duty": ""} for i in range(MAX_SCHEDULE_ROWS + 1)]
today = date.today()
start = today.replace(month=1, day=1)
payload = {"meta": {"start_date": start.isoformat()}, "schedule": rows}
raw = json.dumps(payload).encode("utf-8")
with pytest.raises(DutyScheduleParseError, match="too many|max"):
parse_duty_schedule(raw)
def test_parse_full_name_too_long():
"""full_name longer than MAX_FULL_NAME_LENGTH raises DutyScheduleParseError."""
long_name = "A" * (MAX_FULL_NAME_LENGTH + 1)
today = date.today()
start = today.replace(month=1, day=1)
raw = (
f'{{"meta": {{"start_date": "{start.isoformat()}"}}, '
f'"schedule": [{{"name": "{long_name}", "duty": ""}}]}}'
).encode("utf-8")
with pytest.raises(DutyScheduleParseError, match="exceed|character"):
parse_duty_schedule(raw)

View File

@@ -90,10 +90,13 @@ def test_get_contact_button_markup_empty_username_returns_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)."""
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
from telegram import InlineKeyboardMarkup
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
with (
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
patch.object(config, "MINI_APP_SHORT_NAME", ""),
):
with patch.object(mod, "t", return_value="View contacts"):
result = mod._get_contact_button_markup("en")
assert result is not None
@@ -107,6 +110,22 @@ def test_get_contact_button_markup_returns_markup_when_username_set():
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
from telegram import InlineKeyboardMarkup
with (
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
patch.object(config, "MINI_APP_SHORT_NAME", "DutyApp"),
):
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)
btn = result.inline_keyboard[0][0]
assert btn.url == "https://t.me/MyDutyBot/DutyApp?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."""
@@ -277,8 +296,8 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
@pytest.mark.asyncio
async def test_update_group_pin_repin_raises_still_schedules_next():
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
"""update_group_pin: send_message ok, unpin raises (e.g. no pinned message), pin succeeds -> save_pin and schedule called."""
new_msg = MagicMock()
new_msg.message_id = 888
context = MagicMock()
@@ -287,9 +306,45 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
context.bot = MagicMock()
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock(
side_effect=Forbidden("Not enough rights")
side_effect=BadRequest("Chat has no pinned message")
)
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = 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=(3, "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=222, text="Text", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=222)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=222, message_id=888, disable_notification=False
)
mock_save.assert_called_once_with(222, 888)
mock_schedule.assert_called_once_with(context.application, 222, None)
@pytest.mark.asyncio
async def test_update_group_pin_pin_raises_no_save_still_schedules_next():
"""update_group_pin: send_message ok, unpin ok, pin raises -> no _sync_save_pin, schedule still called, log."""
new_msg = MagicMock()
new_msg.message_id = 888
context = MagicMock()
context.job = MagicMock()
context.job.data = {"chat_id": 222}
context.bot = MagicMock()
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
@@ -308,7 +363,7 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
)
mock_save.assert_not_called()
mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
mock_schedule.assert_called_once_with(context.application, 222, None)
@@ -371,7 +426,7 @@ async def test_pin_duty_cmd_group_only_reply():
@pytest.mark.asyncio
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
"""pin_duty_cmd in group with existing pin record -> pin, schedule next update, reply pinned."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
@@ -382,16 +437,22 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
context = MagicMock()
context.bot = MagicMock()
context.bot.pin_chat_message = AsyncMock()
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=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, "_get_next_shift_end_sync", return_value=None):
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 = "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
)
mock_schedule.assert_called_once_with(context.application, 100, None)
update.message.reply_text.assert_called_once_with("Pinned")
@@ -891,8 +952,8 @@ async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
@pytest.mark.asyncio
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
"""restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
application = MagicMock()
application.job_queue = MagicMock()
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
@@ -905,8 +966,8 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
) as mock_schedule:
await mod.restore_group_pin_jobs(application)
assert mock_schedule.call_count == 2
mock_schedule.assert_any_call(application, 10, None)
mock_schedule.assert_any_call(application, 20, None)
mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)
# --- _refresh_pin_for_chat untrusted ---

View File

@@ -278,8 +278,8 @@ async def test_handle_duty_schedule_document_non_json_replies_need_json():
@pytest.mark.asyncio
async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user_data():
"""handle_duty_schedule_document: parse_duty_schedule raises DutyScheduleParseError -> reply, clear user_data."""
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
message = MagicMock()
message.document = _make_document()
message.reply_text = AsyncMock()
@@ -299,17 +299,17 @@ async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user
with patch.object(mod, "parse_duty_schedule") as mock_parse:
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
with patch.object(mod, "t") as mock_t:
mock_t.return_value = "Parse error: Bad JSON"
mock_t.return_value = "The file could not be parsed."
await mod.handle_duty_schedule_document(update, context)
message.reply_text.assert_called_once_with("Parse error: Bad JSON")
mock_t.assert_called_with("en", "import.parse_error", error="Bad JSON")
message.reply_text.assert_called_once_with("The file could not be parsed.")
mock_t.assert_called_with("en", "import.parse_error_generic")
assert "awaiting_duty_schedule_file" not in context.user_data
assert "handover_utc_time" not in context.user_data
@pytest.mark.asyncio
async def test_handle_duty_schedule_document_import_error_replies_and_clears_user_data():
"""handle_duty_schedule_document: run_import in executor raises -> reply import_error, clear user_data."""
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
message = MagicMock()
message.document = _make_document()
message.reply_text = AsyncMock()
@@ -340,10 +340,10 @@ async def test_handle_duty_schedule_document_import_error_replies_and_clears_use
with patch.object(mod, "run_import") as mock_run:
mock_run.side_effect = ValueError("DB error")
with patch.object(mod, "t") as mock_t:
mock_t.return_value = "Import error: DB error"
mock_t.return_value = "Import failed. Please try again."
await mod.handle_duty_schedule_document(update, context)
message.reply_text.assert_called_once_with("Import error: DB error")
mock_t.assert_called_with("en", "import.import_error", error="DB error")
message.reply_text.assert_called_once_with("Import failed. Please try again.")
mock_t.assert_called_with("en", "import.import_error_generic")
assert "awaiting_duty_schedule_file" not in context.user_data
assert "handover_utc_time" not in context.user_data

View File

@@ -1,48 +1,46 @@
"""Unit tests for duty_teller.i18n: get_lang, t, fallback to en."""
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import duty_teller.config as config
from duty_teller.i18n import get_lang, t
def test_get_lang_none_returns_en():
assert get_lang(None) == "en"
def test_get_lang_always_returns_default_language():
"""get_lang ignores user and always returns config.DEFAULT_LANGUAGE."""
assert get_lang(None) == config.DEFAULT_LANGUAGE
user_ru = MagicMock()
user_ru.language_code = "ru"
assert get_lang(user_ru) == config.DEFAULT_LANGUAGE
user_en = MagicMock()
user_en.language_code = "en"
assert get_lang(user_en) == config.DEFAULT_LANGUAGE
user_any = MagicMock(spec=[])
assert get_lang(user_any) == config.DEFAULT_LANGUAGE
def test_get_lang_ru_returns_ru():
user = MagicMock()
user.language_code = "ru"
assert get_lang(user) == "ru"
def test_get_lang_returns_ru_when_default_language_is_ru():
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
with patch("duty_teller.i18n.core.config") as mock_cfg:
mock_cfg.DEFAULT_LANGUAGE = "ru"
from duty_teller.i18n.core import get_lang as core_get_lang
assert core_get_lang(None) == "ru"
user = MagicMock()
user.language_code = "en"
assert core_get_lang(user) == "ru"
def test_get_lang_ru_ru_returns_ru():
user = MagicMock()
user.language_code = "ru-RU"
assert get_lang(user) == "ru"
def test_get_lang_returns_en_when_default_language_is_en():
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
with patch("duty_teller.i18n.core.config") as mock_cfg:
mock_cfg.DEFAULT_LANGUAGE = "en"
from duty_teller.i18n.core import get_lang as core_get_lang
def test_get_lang_en_returns_en():
user = MagicMock()
user.language_code = "en"
assert get_lang(user) == "en"
def test_get_lang_uk_returns_en():
user = MagicMock()
user.language_code = "uk"
assert get_lang(user) == "en"
def test_get_lang_empty_returns_en():
user = MagicMock()
user.language_code = ""
assert get_lang(user) == "en"
def test_get_lang_missing_attr_returns_en():
user = MagicMock(spec=[]) # no language_code
assert get_lang(user) == "en"
assert core_get_lang(None) == "en"
user = MagicMock()
user.language_code = "ru"
assert core_get_lang(user) == "en"
def test_t_en_start_greeting():

View File

@@ -24,14 +24,23 @@ def test_main_builds_app_and_starts_thread():
mock_scope.return_value.__exit__.return_value = None
with patch("duty_teller.run.require_bot_token"):
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
with patch("duty_teller.run.register_handlers") as mock_register:
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
with patch("duty_teller.db.session.session_scope", mock_scope):
mock_thread = MagicMock()
mock_thread_class.return_value = mock_thread
with pytest.raises(KeyboardInterrupt):
main()
with patch("duty_teller.run.config") as mock_cfg:
mock_cfg.MINI_APP_SKIP_AUTH = False
mock_cfg.HTTP_HOST = "127.0.0.1"
mock_cfg.HTTP_PORT = 8080
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
with patch("duty_teller.run.register_handlers") as mock_register:
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
with patch(
"duty_teller.run._wait_for_http_ready", return_value=True
):
with patch(
"duty_teller.db.session.session_scope", mock_scope
):
mock_thread = MagicMock()
mock_thread_class.return_value = mock_thread
with pytest.raises(KeyboardInterrupt):
main()
mock_register.assert_called_once_with(mock_app)
mock_builder.token.assert_called_once()
mock_thread.start.assert_called_once()

View File

@@ -1,5 +1,8 @@
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
from unittest.mock import patch
import duty_teller.config as config
from duty_teller.api.telegram_auth import (
validate_init_data,
validate_init_data_with_reason,
@@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data():
def test_user_without_username_but_with_id_succeeds_with_reason():
"""With validate_init_data_with_reason, valid user.id is enough; username may be None."""
"""With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE."""
bot_token = "123:ABC"
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
init_data = make_init_data(user, bot_token)
@@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason():
assert telegram_user_id == 456
assert username is None
assert reason == "ok"
assert lang == "ru"
assert lang == config.DEFAULT_LANGUAGE
def test_user_without_id_returns_no_user_id():
"""When user object exists but has no 'id', return no_user_id."""
"""When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE."""
bot_token = "123:ABC"
user = {"first_name": "Test"} # no id
init_data = make_init_data(user, bot_token)
@@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id():
assert telegram_user_id is None
assert username is None
assert reason == "no_user_id"
assert lang == "en"
assert lang == config.DEFAULT_LANGUAGE
def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang():
"""Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code."""
with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"):
user = {"id": 1, "first_name": "U", "language_code": "en"}
init_data = make_init_data(user, "123:ABC")
_, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC")
assert reason == "ok"
assert lang == "ru"
def test_empty_init_data_returns_none():

View File

@@ -1,6 +1,6 @@
"""Unit tests for utils (dates, user, handover)."""
from datetime import date
from datetime import date, timedelta
import pytest
@@ -62,6 +62,23 @@ def test_validate_date_range_from_after_to():
assert exc_info.value.kind == "from_after_to"
def test_validate_date_range_too_large():
"""Range longer than MAX_DATE_RANGE_DAYS raises range_too_large."""
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
# from 2023-01-01 to 2025-06-01 is more than 731 days
with pytest.raises(DateRangeValidationError) as exc_info:
validate_date_range("2023-01-01", "2025-06-01")
assert exc_info.value.kind == "range_too_large"
# Exactly MAX_DATE_RANGE_DAYS + 1 day
from_d = date(2024, 1, 1)
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
with pytest.raises(DateRangeValidationError) as exc_info:
validate_date_range(from_d.isoformat(), to_d.isoformat())
assert exc_info.value.kind == "range_too_large"
# --- user ---

41
webapp-next/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
webapp-next/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "export",
basePath: "/app",
trailingSlash: true,
images: { unoptimized: true },
};
export default nextConfig;

13495
webapp-next/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
webapp-next/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "webapp-next",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint src/",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@telegram-apps/sdk-react": "^3.3.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.576.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^26.0.0",
"shadcn": "^3.8.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,52 @@
/**
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
*/
"use client";
import "./globals.css";
import { getLang, translate } from "@/i18n/messages";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const lang = getLang();
return (
<html
lang={lang === "ru" ? "ru" : "en"}
data-theme="dark"
suppressHydrationWarning
>
<head>
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
</head>
<body className="antialiased">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")}
</h1>
<p className="text-center text-muted-foreground">
{translate(lang, "error_boundary.description")}
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{translate(lang, "error_boundary.reload")}
</button>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,314 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is([data-theme="dark"] *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: system-ui, -apple-system, sans-serif;
--font-mono: ui-monospace, monospace;
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
/* App design tokens (Telegram Mini App) */
--color-surface: var(--surface);
--color-duty: var(--duty);
--color-today: var(--today);
--color-unavailable: var(--unavailable);
--color-vacation: var(--vacation);
--color-error: var(--error);
--max-width-app: 420px;
}
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
:root {
--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, var(--tg-theme-subtitle-text-color, #708499));
--accent: var(--tg-theme-link-color, #6ab3f3);
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
--card: var(--tg-theme-section-bg-color, var(--surface));
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
--primary: var(--tg-theme-button-color, var(--accent));
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
--error: var(--tg-theme-destructive-text-color, #e06c75);
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
--duty: #5c9b4a;
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
--unavailable: #b8860b;
--vacation: #5a9bb8;
--timeline-date-width: 3.6em;
--timeline-track-width: 10px;
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
--transition-fast: 0.15s;
--transition-normal: 0.25s;
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
--radius: 0.625rem;
--calendar-block-min-height: 260px;
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
--calendar-grid-min-height: 264px;
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
--calendar-row-min-height: 2.75rem;
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
--background: var(--bg);
--foreground: var(--text);
--card-foreground: var(--text);
--popover: var(--surface);
--popover-foreground: var(--text);
--secondary: var(--surface);
--secondary-foreground: var(--text);
--muted-foreground: var(--muted);
--accent-foreground: var(--bg);
--destructive: var(--error);
--input: color-mix(in srgb, var(--text) 15%, transparent);
--ring: var(--accent);
--chart-1: var(--duty);
--chart-2: var(--vacation);
--chart-3: var(--unavailable);
--chart-4: var(--today);
--chart-5: var(--accent);
}
/* Light theme: full Telegram themeParams (14 params) mapping */
[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, var(--tg-theme-subtitle-text-color, #6b7089));
--accent: var(--tg-theme-link-color, #2e7de0);
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
--card: var(--tg-theme-section-bg-color, #e0e2e6);
--section-header: var(--tg-theme-section-header-text-color, #343b58);
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
--primary: var(--tg-theme-button-color, #2e7de0);
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
--error: var(--tg-theme-destructive-text-color, #c43b3b);
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
--duty: #587d0a;
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
--unavailable: #b8860b;
--vacation: #0d6b9e;
}
/* Dark theme: full Telegram themeParams (14 params) mapping */
[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, var(--tg-theme-subtitle-text-color, #708499));
--accent: var(--tg-theme-link-color, #6ab3f3);
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
--card: var(--tg-theme-section-bg-color, #232e3c);
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
--primary: var(--tg-theme-button-color, #6ab3f3);
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
--error: var(--tg-theme-destructive-text-color, #e06c75);
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
--duty: #5c9b4a;
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
--unavailable: #b8860b;
--vacation: #5a9bb8;
}
/* === Layout & base (ported from webapp/css/base.css) */
html {
scrollbar-gutter: stable;
scrollbar-width: none;
-ms-overflow-style: none;
overscroll-behavior: none;
}
html::-webkit-scrollbar {
display: none;
}
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
.container-app {
max-width: var(--max-width-app, 420px);
margin-left: auto;
margin-right: auto;
padding: 12px;
padding-top: 0;
padding-bottom: env(safe-area-inset-bottom, 12px);
border-radius: 12px;
}
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
.duty-timeline-date:not(.duty-timeline-date--today)::before {
content: "";
position: absolute;
left: 0;
bottom: 4px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 2px;
background: linear-gradient(
to right,
var(--muted-fade) 0%,
var(--muted-fade) 50%,
var(--muted) 70%,
var(--muted) 100%
);
}
.duty-timeline-date:not(.duty-timeline-date--today)::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 6px;
background: var(--muted);
}
/* Duty list: timeline date cell (today) — horizontal stripe + vertical tick in today color (same geometry as non-today) */
.duty-timeline-date--today::before {
content: "";
position: absolute;
left: 0;
bottom: 5px;
width: calc(100% + var(--timeline-track-width) / 2);
height: 1px;
background: var(--today);
}
.duty-timeline-date--today::after {
content: "";
position: absolute;
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
bottom: 2px;
width: 2px;
height: 7px;
background: var(--today);
}
/* Duty list: current duty card — ensure left stripe uses --today (matches "Today" label and date stripe) */
[data-current-duty] .border-l-today {
border-left-color: var(--today);
border-left-width: 3px;
}
/* Duty list: flip card (front = duty info, back = contacts) */
.duty-flip-card {
perspective: 600px;
}
.duty-flip-inner {
transform-style: preserve-3d;
}
.duty-flip-front {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.duty-flip-back {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Safe area for Telegram Mini App (notch / status bar). */
.pt-safe {
padding-top: env(safe-area-inset-top, 0);
}
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
.sticky.is-scrolled {
box-shadow: 0 1px 0 0 var(--border);
}
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
.calendar-grid {
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
}
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.current-duty-card {
box-shadow: var(--shadow-card);
animation: card-appear 0.3s var(--ease-out);
}
.current-duty-card--no-duty {
border-top-color: var(--muted);
}
@layer base {
* {
box-sizing: border-box;
@apply border-border outline-ring/50;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
-webkit-tap-highlight-color: transparent;
}
}

View File

@@ -0,0 +1,48 @@
import type { Metadata, Viewport } from "next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TelegramProvider } from "@/components/providers/TelegramProvider";
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
import "./globals.css";
export const metadata: Metadata = {
title: "Duty Teller",
description: "Team duty shift calendar and reminders",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
<script
dangerouslySetInnerHTML={{
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
}}
/>
<script src="/app/config.js" />
</head>
<body className="antialiased">
<TelegramProvider>
<AppErrorBoundary>
<TooltipProvider>{children}</TooltipProvider>
</AppErrorBoundary>
</TelegramProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Next.js 404 page. Shown when notFound() is called or route is unknown.
* For static export with a single route this is rarely hit; added for consistency.
*/
"use client";
import Link from "next/link";
import { useTranslation } from "@/i18n/use-translation";
export default function NotFound() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p>
<Link
href="/"
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{t("not_found.open_calendar")}
</Link>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/**
* Integration test for main page: calendar and header visible, lang from store.
* Ported from webapp/js/main.test.js applyLangToUi.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import Page from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
}));
vi.mock("@/hooks/use-month-data", () => ({
useMonthData: () => ({
retry: vi.fn(),
}),
}));
describe("Page", () => {
beforeEach(() => {
resetAppStore();
});
it("renders calendar and header when store has default state", async () => {
render(<Page />);
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
});
it("sets document title and lang from store lang", async () => {
useAppStore.getState().setLang("en");
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
expect(document.title).toBe("Duty Calendar");
expect(document.documentElement.lang).toBe("en");
});
it("sets document title for ru when store lang is ru", async () => {
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
await waitFor(() => {
expect(document.title).toBe("Календарь дежурств");
});
});
});

View File

@@ -0,0 +1,70 @@
/**
* Main Mini App page: current duty deep link or calendar view.
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
*/
"use client";
import { useCallback, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage";
export default function Home() {
useTelegramTheme();
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
useAppInit({ isAllowed, startParam });
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore(
useShallow((s) => ({
currentView: s.currentView,
setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay,
appContentReady: s.appContentReady,
}))
);
// When content is ready, tell Telegram to hide native loading and show our app.
useEffect(() => {
if (appContentReady) {
callMiniAppReadyOnce();
}
}, [appContentReady]);
const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar");
setSelectedDay(null);
}, [setCurrentView, setSelectedDay]);
const content =
currentView === "currentDuty" ? (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
<CurrentDutyView
onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"}
/>
</div>
) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
);
return (
<div
style={{
visibility: appContentReady ? "visible" : "hidden",
minHeight: "100vh",
}}
>
{content}
</div>
);
}

View File

@@ -0,0 +1,76 @@
/**
* Error boundary that catches render errors in the app tree and shows a fallback
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
* on React context that might be broken.
*/
"use client";
import React from "react";
import { getLang } from "@/i18n/messages";
import { translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
interface AppErrorBoundaryProps {
children: React.ReactNode;
}
interface AppErrorBoundaryState {
hasError: boolean;
}
/**
* Catches JavaScript errors in the child tree and renders a fallback UI
* instead of crashing. Provides a Reload button to recover.
*/
export class AppErrorBoundary extends React.Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
constructor(props: AppErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
if (typeof console !== "undefined" && console.error) {
console.error("AppErrorBoundary caught an error:", error, errorInfo);
}
}
handleReload = (): void => {
if (typeof window !== "undefined") {
window.location.reload();
}
};
render(): React.ReactNode {
if (this.state.hasError) {
const lang = getLang();
const message = translate(lang, "error_boundary.message");
const reloadLabel = translate(lang, "error_boundary.reload");
return (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
role="alert"
>
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
<Button
type="button"
variant="default"
size="sm"
onClick={this.handleReload}
className="bg-primary text-primary-foreground hover:opacity-90"
>
{reloadLabel}
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,179 @@
/**
* Calendar view layout: header, grid, duty list, day detail.
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
*/
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe";
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied";
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
const STICKY_HEIGHT_FALLBACK_PX = 268;
export interface CalendarPageProps {
/** Whether the user is allowed (for data loading). */
isAllowed: boolean;
/** Raw initData string for API auth. */
initDataRaw: string | undefined;
}
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const dayDetailRef = useRef<DayDetailHandle>(null);
const calendarStickyRef = useRef<HTMLDivElement>(null);
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
useEffect(() => {
const el = calendarStickyRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setStickyBlockHeight(entry.contentRect.height);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const {
currentMonth,
pendingMonth,
loading,
error,
accessDenied,
accessDeniedDetail,
duties,
calendarEvents,
selectedDay,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
setAppContentReady,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
pendingMonth: s.pendingMonth,
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
accessDeniedDetail: s.accessDeniedDetail,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
setAppContentReady: s.setAppContentReady,
}))
);
const { retry } = useMonthData({
initDataRaw,
enabled: isAllowed,
});
const now = new Date();
const isCurrentMonth =
currentMonth.getFullYear() === now.getFullYear() &&
currentMonth.getMonth() === now.getMonth();
useAutoRefresh(retry, isCurrentMonth);
const navDisabled = loading || accessDenied || selectedDay !== null;
const handlePrevMonth = useCallback(() => {
if (navDisabled) return;
prevMonth();
}, [navDisabled, prevMonth]);
const handleNextMonth = useCallback(() => {
if (navDisabled) return;
nextMonth();
}, [navDisabled, nextMonth]);
useSwipe(
calendarStickyRef,
handleNextMonth,
handlePrevMonth,
{ threshold: 50, disabled: navDisabled }
);
useStickyScroll(calendarStickyRef);
const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => {
const [y, m] = dateKey.split("-").map(Number);
if (
y !== currentMonth.getFullYear() ||
m !== currentMonth.getMonth() + 1
) {
return;
}
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
},
[currentMonth]
);
const handleCloseDayDetail = useCallback(() => {
setSelectedDay(null);
}, [setSelectedDay]);
const readyCalledRef = useRef(false);
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
useEffect(() => {
if ((!loading || accessDenied) && !readyCalledRef.current) {
readyCalledRef.current = true;
setAppContentReady(true);
}
}, [loading, accessDenied, setAppContentReady]);
return (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
<div
ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
>
<CalendarHeader
month={currentMonth}
disabled={navDisabled}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
<CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
</div>
{accessDenied && (
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
)}
{!accessDenied && error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!accessDenied && !error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Unit tests for CalendarDay: click opens day detail only for current month;
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CalendarDay } from "./CalendarDay";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarDay", () => {
const defaultProps = {
dateKey: "2025-02-15",
dayOfMonth: 15,
isToday: false,
duties: [],
eventSummaries: [],
onDayClick: () => {},
};
beforeEach(() => {
resetAppStore();
});
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={false}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).toHaveBeenCalledTimes(1);
expect(onDayClick).toHaveBeenCalledWith(
"2025-02-15",
expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
top: expect.any(Number),
left: expect.any(Number),
})
);
});
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={true}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).not.toHaveBeenCalled();
});
it("sets aria-disabled on the button when isOtherMonth is true", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("is not disabled for interaction when isOtherMonth is false", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button.getAttribute("aria-disabled")).not.toBe("true");
});
});

View File

@@ -0,0 +1,123 @@
/**
* Single calendar day cell: date number and day indicators. Click opens day detail.
* Ported from webapp/js/calendar.js day cell rendering.
*/
"use client";
import React, { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { dateKeyToDDMM } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
import { DayIndicators } from "./DayIndicators";
export interface CalendarDayProps {
/** YYYY-MM-DD key for this day. */
dateKey: string;
/** Day of month (131) for display. */
dayOfMonth: number;
isToday: boolean;
isOtherMonth: boolean;
/** Duties overlapping this day (for indicators and tooltip). */
duties: DutyWithUser[];
/** External calendar event summaries for this day. */
eventSummaries: string[];
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
}
function CalendarDayInner({
dateKey,
dayOfMonth,
isToday,
isOtherMonth,
duties,
eventSummaries,
onDayClick,
}: CalendarDayProps) {
const { t } = useTranslation();
const { dutyList, unavailableList, vacationList } = useMemo(
() => ({
dutyList: duties.filter((d) => d.event_type === "duty"),
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
vacationList: duties.filter((d) => d.event_type === "vacation"),
}),
[duties]
);
const hasEvent = eventSummaries.length > 0;
const showIndicator = !isOtherMonth;
const hasAny = duties.length > 0 || hasEvent;
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
if (hasAny && showIndicator) {
const counts: string[] = [];
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
if (unavailableList.length)
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
if (vacationList.length)
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
if (hasEvent) counts.push(t("hint.events"));
ariaParts.push(counts.join(", "));
} else {
ariaParts.push(t("aria.day_info"));
}
const ariaLabel = ariaParts.join("; ");
const content = (
<button
type="button"
aria-label={ariaLabel}
aria-disabled={isOtherMonth}
data-date={dateKey}
className={cn(
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
isOtherMonth &&
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
!isOtherMonth && [
"bg-surface hover:bg-[var(--surface-hover-10)]",
"active:scale-[0.98] cursor-pointer",
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
],
showIndicator && hasAny && "font-bold",
showIndicator &&
hasEvent &&
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
isToday &&
hasEvent &&
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
)}
onClick={(e) => {
if (isOtherMonth) return;
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
}}
>
<span className="num">{dayOfMonth}</span>
{showIndicator && (duties.length > 0 || hasEvent) && (
<DayIndicators
dutyCount={dutyList.length}
unavailableCount={unavailableList.length}
vacationCount={vacationList.length}
hasEvents={hasEvent}
isToday={isToday}
/>
)}
</button>
);
return content;
}
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
return (
prev.dateKey === next.dateKey &&
prev.dayOfMonth === next.dayOfMonth &&
prev.isToday === next.isToday &&
prev.isOtherMonth === next.isOtherMonth &&
prev.duties === next.duties &&
prev.eventSummaries === next.eventSummaries &&
prev.onDayClick === next.onDayClick
);
}
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);

View File

@@ -0,0 +1,88 @@
/**
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
* Ported from webapp/js/calendar.test.js renderCalendar.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { CalendarGrid } from "./CalendarGrid";
import { CalendarHeader } from "./CalendarHeader";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarGrid", () => {
beforeEach(() => {
resetAppStore();
});
it("renders 42 cells (6 weeks)", () => {
const currentMonth = new Date(2025, 0, 1); // January 2025
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const cells = screen.getAllByRole("button", { name: /;/ });
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
const currentMonth = new Date(2025, 0, 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const grid = screen.getByRole("grid", { name: "Calendar" });
const buttons = grid.querySelectorAll('button[data-date]');
expect(buttons.length).toBe(42);
buttons.forEach((el) => {
const date = el.getAttribute("data-date");
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
it("adds today styling to cell matching today", () => {
const today = new Date();
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const todayKey = `${year}-${month}-${day}`;
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
expect(todayCell).toBeTruthy();
expect(todayCell?.className).toMatch(/today|bg-today/);
});
});
describe("CalendarHeader", () => {
beforeEach(() => {
resetAppStore();
});
it("sets month title from lang and given year/month", () => {
render(
<CalendarHeader
month={new Date(2025, 1, 1)}
onPrevMonth={() => {}}
onNextMonth={() => {}}
/>
);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("February");
expect(heading).toHaveTextContent("2025");
});
});

View File

@@ -0,0 +1,93 @@
/**
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
* Ported from webapp/js/calendar.js renderCalendar.
*/
"use client";
import { useMemo } from "react";
import {
firstDayOfMonth,
getMonday,
localDateString,
} from "@/lib/date-utils";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { cn } from "@/lib/utils";
import { CalendarDay } from "./CalendarDay";
export interface CalendarGridProps {
/** Currently displayed month. */
currentMonth: Date;
/** All duties for the visible range (will be grouped by date). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
className?: string;
}
const CELLS = 42;
export function CalendarGrid({
currentMonth,
duties,
calendarEvents,
onDayClick,
className,
}: CalendarGridProps) {
const dutiesByDateMap = useMemo(
() => dutiesByDate(duties),
[duties]
);
const calendarEventsByDateMap = useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const todayKey = localDateString(new Date());
const cells = useMemo(() => {
const first = firstDayOfMonth(currentMonth);
const start = getMonday(first);
const result: { date: Date; key: string; month: number }[] = [];
const d = new Date(start);
for (let i = 0; i < CELLS; i++) {
const key = localDateString(d);
result.push({ date: new Date(d), key, month: d.getMonth() });
d.setDate(d.getDate() + 1);
}
return result;
}, [currentMonth]);
return (
<div
className={cn(
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
className
)}
role="grid"
aria-label="Calendar"
>
{cells.map(({ date, key, month }, i) => {
const isOtherMonth = month !== currentMonth.getMonth();
const dayDuties = dutiesByDateMap[key] ?? [];
const eventSummaries = calendarEventsByDateMap[key] ?? [];
return (
<div key={`cell-${i}`} role="gridcell" className="min-h-0">
<CalendarDay
dateKey={key}
dayOfMonth={date.getDate()}
isToday={key === todayKey}
isOtherMonth={isOtherMonth}
duties={dayDuties}
eventSummaries={eventSummaries}
onDayClick={onDayClick}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,82 @@
/**
* Calendar header: month title, prev/next navigation, weekday labels.
* Replaces the header from webapp index.html and calendar.js month title.
*/
"use client";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
} from "lucide-react";
export interface CalendarHeaderProps {
/** Currently displayed month (used for title). */
month: Date;
/** Whether month navigation is disabled (e.g. during loading). */
disabled?: boolean;
onPrevMonth: () => void;
onNextMonth: () => void;
className?: string;
}
export function CalendarHeader({
month,
disabled = false,
onPrevMonth,
onNextMonth,
className,
}: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
const labels = weekdayLabels();
return (
<header className={cn("flex flex-col", className)}>
<div className="flex items-center justify-between mb-3">
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.prev_month")}
disabled={disabled}
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex flex-col items-center gap-0.5">
<h1
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
aria-live="polite"
aria-atomic="true"
>
{monthName(monthIndex)} {year}
</h1>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
{labels.map((label, i) => (
<span key={i} aria-hidden>
{label}
</span>
))}
</div>
</header>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Unit tests for DayIndicators: rounding is position-based (first / last segment),
* not by indicator type, so one or multiple segments form one pill.
*/
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { DayIndicators } from "./DayIndicators";
const baseProps = {
dutyCount: 0,
unavailableCount: 0,
vacationCount: 0,
hasEvents: false,
};
describe("DayIndicators", () => {
it("renders nothing when no indicators", () => {
const { container } = render(<DayIndicators {...baseProps} />);
expect(container.firstChild).toBeNull();
});
it("renders one segment as pill (e.g. vacation only)", () => {
const { container } = render(
<DayIndicators {...baseProps} vacationCount={1} />
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain("[&>:first-child]:rounded-l-[3px]");
expect(wrapper?.className).toContain("[&>:last-child]:rounded-r-[3px]");
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(1);
});
it("renders two segments with positional rounding (duty + vacation)", () => {
const { container } = render(
<DayIndicators {...baseProps} dutyCount={1} vacationCount={1} />
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain(
"[&>:first-child]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child]:rounded-r-[3px]"
);
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(2);
});
it("renders three segments with first left-rounded, last right-rounded (duty + unavailable + vacation)", () => {
const { container } = render(
<DayIndicators
{...baseProps}
dutyCount={1}
unavailableCount={1}
vacationCount={1}
/>
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain(
"[&>:first-child]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child]:rounded-r-[3px]"
);
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(3);
});
});

View File

@@ -0,0 +1,65 @@
/**
* Colored segments (pill bar) for calendar day: duty (green), unavailable (amber), vacation (blue), events (accent).
* Ported from webapp calendar day-indicator markup and markers.css.
*
* Rounding is position-based (first / last segment), so one or multiple segments always form a pill:
* first segment gets left rounding, last segment gets right rounding (single segment gets both).
*/
import { cn } from "@/lib/utils";
export interface DayIndicatorsProps {
/** Number of duty slots this day. */
dutyCount: number;
/** Number of unavailable slots. */
unavailableCount: number;
/** Number of vacation slots. */
vacationCount: number;
/** Whether the day has external calendar events (e.g. holiday). */
hasEvents: boolean;
/** When true (e.g. today cell), use darker segments for contrast. */
isToday?: boolean;
className?: string;
}
export function DayIndicators({
dutyCount,
unavailableCount,
vacationCount,
hasEvents,
isToday = false,
className,
}: DayIndicatorsProps) {
const hasAny = dutyCount > 0 || unavailableCount > 0 || vacationCount > 0 || hasEvents;
if (!hasAny) return null;
const dotClass = (variant: "duty" | "unavailable" | "vacation" | "events") =>
cn(
"min-w-0 flex-1 h-1 max-h-1.5",
variant === "duty" && "bg-duty",
variant === "unavailable" && "bg-unavailable",
variant === "vacation" && "bg-vacation",
variant === "events" && "bg-accent",
isToday && variant === "duty" && "bg-[var(--indicator-today-duty)]",
isToday && variant === "unavailable" && "bg-[var(--indicator-today-unavailable)]",
isToday && variant === "vacation" && "bg-[var(--indicator-today-vacation)]",
isToday && variant === "events" && "bg-[var(--indicator-today-events)]"
);
return (
<div
className={cn(
"flex w-[65%] justify-center gap-0.5 mt-1.5",
"[&>:first-child]:rounded-l-[3px]",
"[&>:last-child]:rounded-r-[3px]",
className
)}
aria-hidden
>
{dutyCount > 0 && <span className={dotClass("duty")} />}
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
{vacationCount > 0 && <span className={dotClass("vacation")} />}
{hasEvents && <span className={dotClass("events")} />}
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Calendar components and data helpers.
*/
export { CalendarGrid } from "./CalendarGrid";
export { CalendarHeader } from "./CalendarHeader";
export { CalendarDay } from "./CalendarDay";
export { DayIndicators } from "./DayIndicators";
export type { DayIndicatorsProps } from "./DayIndicators";
export type { CalendarGridProps } from "./CalendarGrid";
export type { CalendarHeaderProps } from "./CalendarHeader";
export type { CalendarDayProps } from "./CalendarDay";

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { resetAppStore } from "@/test/test-utils";
describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
});
it("returns null when phone and username are missing", () => {
const { container } = render(
<ContactLinks phone={null} username={null} />
);
expect(container.firstChild).toBeNull();
});
it("renders phone only with label and tel: link", () => {
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
});
it("displays phone formatted for Russian numbers", () => {
render(<ContactLinks phone="79146522209" username={null} />);
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
});
it("renders username only with label and t.me link", () => {
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
});
it("renders both phone and username with labels", () => {
render(
<ContactLinks
phone="+79001112233"
username="bob"
showLabels
/>
);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
expect(screen.getByText(/@bob/)).toBeInTheDocument();
});
it("strips leading @ from username and displays with @", () => {
render(<ContactLinks phone={null} username="@alice" />);
const link = document.querySelector('a[href*="t.me/alice"]');
expect(link).toBeInTheDocument();
expect(link?.textContent).toContain("@alice");
});
});

View File

@@ -0,0 +1,145 @@
/**
* Contact links (phone, Telegram) for duty cards and day detail.
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
export interface ContactLinksProps {
phone?: string | null;
username?: string | null;
layout?: "inline" | "block";
showLabels?: boolean;
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
contextLabel?: string;
className?: string;
}
const linkClass =
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
/**
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
*/
export function ContactLinks({
phone,
username,
layout = "inline",
showLabels = true,
contextLabel,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
if (!hasPhone && !hasUsername) return null;
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
const ariaTelegram = contextLabel
? t("contact.aria_telegram", { name: contextLabel })
: t("contact.telegram");
if (layout === "block") {
return (
<div className={cn("flex flex-col gap-2", className)}>
{hasPhone && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
</Button>
)}
{hasUsername && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
</a>
</Button>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
if (hasPhone) {
const displayPhone = formatPhoneDisplay(phone!);
parts.push(
showLabels ? (
<span key="phone">
{t("contact.phone")}:{" "}
<a
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
</span>
) : (
<a
key="phone"
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
)
);
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const link = (
<a
key="tg"
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
>
@{cleanUsername}
</a>
);
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
}
return (
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
{parts.map((p, i) => (
<span key={i} className="inline-flex items-center gap-x-1">
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
{p}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Contact links component.
*/
export { ContactLinks } from "./ContactLinks";
export type { ContactLinksProps } from "./ContactLinks";

View File

@@ -0,0 +1,84 @@
/**
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CurrentDutyView } from "./CurrentDutyView";
import { resetAppStore } from "@/test/test-utils";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
}));
vi.mock("@/lib/api", () => ({
fetchDuties: vi.fn().mockResolvedValue([]),
AccessDeniedError: class AccessDeniedError extends Error {
serverDetail?: string;
constructor(m: string, d?: string) {
super(m);
this.serverDetail = d;
}
},
}));
describe("CurrentDutyView", () => {
beforeEach(() => {
resetAppStore();
vi.clearAllMocks();
});
it("shows loading then no-duty message when no active duty", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
});
it("back button calls onBack when clicked", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
fireEvent.click(buttons[buttons.length - 1]);
expect(onBack).toHaveBeenCalled();
});
it("shows Close button when openedFromPin is true", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
});
it("shows contact info not set when duty has no phone or username", async () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
const dutyNoContacts = {
id: 1,
user_id: 1,
start_at: start.toISOString(),
end_at: end.toISOString(),
event_type: "duty" as const,
full_name: "Test User",
phone: null,
username: null,
};
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
});

View File

@@ -0,0 +1,323 @@
/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
* and contact links. Integrates with Telegram BackButton.
* Ported from webapp/js/currentDuty.js.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { fetchDuties, AccessDeniedError } from "@/lib/api";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DutyWithUser } from "@/types";
export interface CurrentDutyViewProps {
/** Called when user taps Back (in-app button or Telegram BackButton). */
onBack: () => void;
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
openedFromPin?: boolean;
}
type ViewState = "loading" | "error" | "ready";
/**
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
*/
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation();
const lang = useAppStore((s) => s.lang);
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading");
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const loadTodayDuties = useCallback(
async (signal?: AbortSignal | null) => {
const today = new Date();
const from = localDateString(today);
const to = from;
const initData = initDataRaw ?? "";
try {
const duties = await fetchDuties(from, to, initData, lang, signal);
if (signal?.aborted) return;
const active = findCurrentDuty(duties);
setDuty(active);
setState("ready");
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
setRemaining(null);
}
} catch (e) {
if (signal?.aborted) return;
setState("error");
const msg =
e instanceof AccessDeniedError && e.serverDetail
? e.serverDetail
: t("error_generic");
setErrorMessage(msg);
setDuty(null);
setRemaining(null);
}
},
[initDataRaw, lang, t]
);
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
useEffect(() => {
const controller = new AbortController();
loadTodayDuties(controller.signal);
return () => controller.abort();
}, [loadTodayDuties]);
// Mark content ready when data is loaded or error, so page can call ready() and show content.
useEffect(() => {
if (state !== "loading") {
setAppContentReady(true);
}
}, [state, setAppContentReady]);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
if (!duty) return;
const interval = setInterval(() => {
setRemaining(getRemainingTime(duty.end_at));
}, 1000);
return () => clearInterval(interval);
}, [duty]);
// Telegram BackButton: show on mount, hide on unmount, handle click.
useEffect(() => {
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onBack);
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [onBack]);
const handleBack = () => {
onBack();
};
const handleClose = () => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
} else {
onBack();
}
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
const primaryButtonAriaLabel = openedFromPin
? t("current_duty.close")
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<span
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
<p className="text-muted-foreground m-0">{t("loading")}</p>
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
</div>
);
}
if (state === "error") {
const handleRetry = () => {
setState("loading");
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6">
<p className="text-error">{errorMessage}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="default"
onClick={handleRetry}
aria-label={t("error.retry")}
>
{t("error.retry")}
</Button>
<Button
variant="outline"
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (!duty) {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
<CardHeader>
<CardTitle>{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<span
className="flex items-center justify-center text-muted-foreground"
aria-hidden
>
<Calendar className="size-12" strokeWidth={1.5} />
</span>
<p className="text-center text-muted-foreground">
{t("current_duty.no_duty")}
</p>
</CardContent>
<CardFooter>
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</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 rem = remaining ?? getRemainingTime(duty.end_at);
const remainingStr = t("current_duty.remaining", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
const endsAtStr = t("current_duty.ends_at", { time: endTime });
const displayTz =
typeof window !== "undefined" &&
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
const shiftLabel = displayTz
? t("current_duty.shift_tz", { tz: displayTz })
: t("current_duty.shift_local");
const hasContacts =
Boolean(duty.phone && String(duty.phone).trim()) ||
Boolean(duty.username && String(duty.username).trim());
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
role="article"
aria-labelledby="current-duty-title"
>
<CardHeader>
<CardTitle
id="current-duty-title"
className="flex items-center gap-2"
>
<span
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{t("current_duty.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="font-medium text-foreground" id="current-duty-name">
{duty.full_name}
</p>
<p className="text-sm text-muted-foreground">
{shiftLabel} {shiftStr}
</p>
<div
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
aria-live="polite"
aria-atomic="true"
>
{remainingStr}
</div>
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
{hasContacts ? (
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
/>
) : (
<p className="text-sm text-muted-foreground">
{t("current_duty.contact_info_not_set")}
</p>
)}
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentDutyView } from "./CurrentDutyView";
export type { CurrentDutyViewProps } from "./CurrentDutyView";

View File

@@ -0,0 +1,77 @@
/**
* Unit tests for DayDetailContent: sorts duties by start_at; duty entries show time and name only (no contact links).
* Ported from webapp/js/dayDetail.test.js buildDayDetailContent.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DayDetailContent } from "./DayDetailContent";
import { resetAppStore } from "@/test/test-utils";
import type { DutyWithUser } from "@/types";
function duty(
full_name: string,
start_at: string,
end_at: string,
extra: Partial<DutyWithUser> = {}
): DutyWithUser {
return {
id: 1,
user_id: 1,
full_name,
start_at,
end_at,
event_type: "duty",
phone: null,
username: null,
...extra,
};
}
describe("DayDetailContent", () => {
beforeEach(() => {
resetAppStore();
});
it("sorts duty list by start_at when input order is wrong", () => {
const dateKey = "2025-02-25";
const duties = [
duty("Петров", "2025-02-25T14:00:00Z", "2025-02-25T18:00:00Z", { id: 2 }),
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T14:00:00Z", { id: 1 }),
];
render(
<DayDetailContent
dateKey={dateKey}
duties={duties}
eventSummaries={[]}
/>
);
expect(screen.getByText("Иванов")).toBeInTheDocument();
expect(screen.getByText("Петров")).toBeInTheDocument();
const body = document.body.innerHTML;
const ivanovPos = body.indexOf("Иванов");
const petrovPos = body.indexOf("Петров");
expect(ivanovPos).toBeLessThan(petrovPos);
});
it("shows duty time and name on one line and does not show contact links", () => {
const dateKey = "2025-03-01";
const duties = [
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
phone: "+79991234567",
username: "alice_dev",
}),
];
render(
<DayDetailContent
dateKey={dateKey}
duties={duties}
eventSummaries={[]}
/>
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(document.querySelector('a[href^="tel:"]')).toBeNull();
expect(document.querySelector('a[href*="t.me"]')).toBeNull();
expect(screen.queryByText(/alice_dev/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,255 @@
/**
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
*/
"use client";
import * as React from "react";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useTranslation } from "@/i18n/use-translation";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
import { DayDetailContent } from "./DayDetailContent";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
/** Empty state for day detail: date and "no duties or events" message. */
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
const { t } = useTranslation();
const todayKey = localDateString(new Date());
const ddmm = dateKeyToDDMM(dateKey);
const title =
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
return (
<div className="flex flex-col gap-4">
<h2
id="day-detail-title"
className="text-[1.1rem] font-semibold leading-tight m-0"
>
{title}
</h2>
<p className="text-sm text-muted-foreground m-0">
{t("day_detail.no_events")}
</p>
</div>
);
}
export interface DayDetailHandle {
/** Open the panel for the given day with popover anchored at the given rect. */
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
}
export interface DayDetailProps {
/** All duties for the visible range (will be filtered by selectedDay). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when the panel should close. */
onClose: () => void;
className?: string;
}
/**
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
*/
function VirtualAnchor({
rect,
className,
}: {
rect: DOMRect;
className?: string;
}) {
return (
<div
className={cn("pointer-events-none fixed z-0", className)}
style={{
left: rect.left,
top: rect.bottom,
width: rect.width,
height: 1,
}}
aria-hidden
/>
);
}
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
const isDesktop = useIsDesktop();
const { t } = useTranslation();
const selectedDay = useAppStore((s) => s.selectedDay);
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
const [exiting, setExiting] = React.useState(false);
const open = selectedDay !== null;
React.useImperativeHandle(
ref,
() => ({
openWithRect(dateKey: string, rect: DOMRect) {
setSelectedDay(dateKey);
setAnchorRect(rect);
},
}),
[setSelectedDay]
);
const handleClose = React.useCallback(() => {
setSelectedDay(null);
setAnchorRect(null);
setExiting(false);
onClose();
}, [setSelectedDay, onClose]);
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
const requestClose = React.useCallback(() => {
setExiting(true);
}, []);
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
React.useEffect(() => {
if (!exiting) return;
const fallback = window.setTimeout(() => {
handleClose();
}, 320);
return () => window.clearTimeout(fallback);
}, [exiting, handleClose]);
const dutiesByDateMap = React.useMemo(
() => dutiesByDate(duties),
[duties]
);
const eventsByDateMap = React.useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
// Close popover/sheet on window resize so anchor position does not become stale.
React.useEffect(() => {
if (!open) return;
const onResize = () => handleClose();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [open, handleClose]);
const content =
selectedDay &&
(hasContent ? (
<DayDetailContent
dateKey={selectedDay}
duties={dayDuties}
eventSummaries={dayEvents}
/>
) : (
<DayDetailEmpty dateKey={selectedDay} />
));
if (!open || !selectedDay) return null;
const panelClassName =
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
const closeButton = (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={requestClose}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
);
const renderSheet = (withHandle: boolean) => (
<Sheet
open={!exiting && open}
onOpenChange={(o) => !o && requestClose()}
>
<SheetContent
side="bottom"
className={cn(
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
className
)}
showCloseButton={false}
onCloseAnimationEnd={handleClose}
>
<div className="relative px-4">
{closeButton}
{withHandle && (
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
)}
<SheetHeader className="p-0">
<SheetTitle id="day-detail-sheet-title" className="sr-only">
{selectedDay}
</SheetTitle>
</SheetHeader>
{content}
</div>
</SheetContent>
</Sheet>
);
if (isDesktop === true && anchorRect != null) {
return (
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
<PopoverAnchor asChild>
<VirtualAnchor rect={anchorRect} />
</PopoverAnchor>
<PopoverContent
side="bottom"
sideOffset={8}
align="center"
className={cn(panelClassName, "relative", className)}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={handleClose}
>
{closeButton}
{content}
</PopoverContent>
</Popover>
);
}
return renderSheet(true);
}
);

View File

@@ -0,0 +1,219 @@
/**
* Shared content for day detail: title and sections (duty, unavailable, vacation, events).
* Ported from webapp/js/dayDetail.js buildDayDetailContent.
*/
"use client";
import { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
import { getDutyMarkerRows } from "@/lib/duty-marker-rows";
import type { DutyWithUser } from "@/types";
import { cn } from "@/lib/utils";
const NBSP = "\u00a0";
export interface DayDetailContentProps {
/** YYYY-MM-DD key for the day. */
dateKey: string;
/** Duties overlapping this day. */
duties: DutyWithUser[];
/** Calendar event summary strings for this day. */
eventSummaries: string[];
className?: string;
}
export function DayDetailContent({
dateKey,
duties,
eventSummaries,
className,
}: DayDetailContentProps) {
const { t } = useTranslation();
const todayKey = localDateString(new Date());
const ddmm = dateKeyToDDMM(dateKey);
const title =
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
const fromLabel = t("hint.from");
const toLabel = t("hint.to");
const dutyList = useMemo(
() =>
duties
.filter((d) => d.event_type === "duty")
.sort(
(a, b) =>
new Date(a.start_at || 0).getTime() -
new Date(b.start_at || 0).getTime()
),
[duties]
);
const unavailableList = useMemo(
() => duties.filter((d) => d.event_type === "unavailable"),
[duties]
);
const vacationList = useMemo(
() => duties.filter((d) => d.event_type === "vacation"),
[duties]
);
const dutyRows = useMemo(() => {
const hasTimes = dutyList.some((it) => it.start_at || it.end_at);
return hasTimes
? getDutyMarkerRows(dutyList, dateKey, NBSP, fromLabel, toLabel)
: dutyList.map((d) => ({
id: d.id,
timePrefix: "",
fullName: d.full_name ?? "",
phone: d.phone,
username: d.username,
}));
}, [dutyList, dateKey, fromLabel, toLabel]);
const uniqueUnavailable = useMemo(
() => [
...new Set(
unavailableList.map((d) => d.full_name ?? "").filter(Boolean)
),
],
[unavailableList]
);
const uniqueVacation = useMemo(
() => [
...new Set(vacationList.map((d) => d.full_name ?? "").filter(Boolean)),
],
[vacationList]
);
const summaries = eventSummaries ?? [];
return (
<div className={cn("flex flex-col gap-4", className)}>
<h2
id="day-detail-title"
className="text-[1.1rem] font-semibold leading-tight m-0"
>
{title}
</h2>
<div className="flex flex-col gap-4">
{dutyList.length > 0 && (
<section
className="day-detail-section-duty"
aria-labelledby="day-detail-duty-heading"
>
<h3
id="day-detail-duty-heading"
className="text-[0.8rem] font-semibold text-duty m-0 mb-1"
>
{t("event_type.duty")}
</h3>
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
{dutyRows.map((r) => (
<li key={r.id}>
<span
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
aria-hidden
>
</span>
<span className="inline-flex items-baseline gap-1">
{r.timePrefix && (
<span className="text-muted-foreground">{r.timePrefix} </span>
)}
<span className="font-semibold">{r.fullName}</span>
</span>
</li>
))}
</ul>
</section>
)}
{uniqueUnavailable.length > 0 && (
<section
className="day-detail-section-unavailable"
aria-labelledby="day-detail-unavailable-heading"
>
<h3
id="day-detail-unavailable-heading"
className="text-[0.8rem] font-semibold text-unavailable m-0 mb-1"
>
{t("event_type.unavailable")}
</h3>
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
{uniqueUnavailable.map((name) => (
<li key={name}>
<span
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
aria-hidden
>
</span>
{name}
</li>
))}
</ul>
</section>
)}
{uniqueVacation.length > 0 && (
<section
className="day-detail-section-vacation"
aria-labelledby="day-detail-vacation-heading"
>
<h3
id="day-detail-vacation-heading"
className="text-[0.8rem] font-semibold text-vacation m-0 mb-1"
>
{t("event_type.vacation")}
</h3>
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
{uniqueVacation.map((name) => (
<li key={name}>
<span
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
aria-hidden
>
</span>
{name}
</li>
))}
</ul>
</section>
)}
{summaries.length > 0 && (
<section
className="day-detail-section-events"
aria-labelledby="day-detail-events-heading"
>
<h3
id="day-detail-events-heading"
className="text-[0.8rem] font-semibold text-accent m-0 mb-1"
>
{t("hint.events")}
</h3>
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
{summaries.map((s) => (
<li key={String(s)}>
<span
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
aria-hidden
>
</span>
{String(s)}
</li>
))}
</ul>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { DayDetail } from "./DayDetail";
export { DayDetailContent } from "./DayDetailContent";
export type { DayDetailContentProps } from "./DayDetailContent";
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";

View File

@@ -0,0 +1,80 @@
/**
* Single duty row: event type label, name, time range.
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
export interface DutyItemProps {
duty: DutyWithUser;
/** Override type label (e.g. "On duty now"). */
typeLabelOverride?: string;
/** Show "until HH:MM" instead of full range (for current duty). */
showUntilEnd?: boolean;
/** Extra class, e.g. for current duty highlight. */
isCurrent?: boolean;
className?: string;
}
const borderByType = {
duty: "border-l-duty",
unavailable: "border-l-unavailable",
vacation: "border-l-vacation",
} as const;
/**
* Renders type badge, name, and time. Timeline cards use event_type for border color.
*/
export function DutyItem({
duty,
typeLabelOverride,
showUntilEnd = false,
isCurrent = false,
className,
}: DutyItemProps) {
const { t } = useTranslation();
const typeLabel =
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
let timeOrRange: string;
if (showUntilEnd && duty.event_type === "duty") {
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
const startStr = formatDateKey(duty.start_at);
const endStr = formatDateKey(duty.end_at);
timeOrRange = startStr === endStr ? startStr : `${startStr} ${endStr}`;
} else {
timeOrRange = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
}
return (
<div
className={cn(
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
"border-l-[3px] shadow-sm",
"min-h-0",
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
className
)}
data-slot="duty-item"
>
<span className="text-xs text-muted col-span-1 row-start-1">
{typeLabel}
</span>
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
{timeOrRange}
</span>
</div>
);
}

View File

@@ -0,0 +1,78 @@
/**
* Unit tests for DutyList: renders timeline, flip card with contacts, duty items.
* Ported from webapp/js/dutyList.test.js.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DutyList } from "./DutyList";
import { useAppStore } from "@/store/app-store";
import { resetAppStore } from "@/test/test-utils";
import type { DutyWithUser } from "@/types";
function duty(
full_name: string,
start_at: string,
end_at: string,
extra: Partial<DutyWithUser> = {}
): DutyWithUser {
return {
id: 1,
user_id: 1,
full_name,
start_at,
end_at,
event_type: "duty",
phone: null,
username: null,
...extra,
};
}
describe("DutyList", () => {
beforeEach(() => {
resetAppStore();
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
});
it("renders no duties message when duties empty and data loaded for month", () => {
useAppStore.getState().setDuties([]);
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />);
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
});
it("renders compact loading placeholder when data not yet loaded for month (no skeleton)", () => {
useAppStore.getState().setDuties([]);
useAppStore.getState().batchUpdate({ dataForMonthKey: null });
render(<DutyList />);
expect(screen.queryByText(/No duties this month/i)).not.toBeInTheDocument();
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
expect(document.querySelector('[data-slot="skeleton"]')).not.toBeInTheDocument();
});
it("renders duty with full_name and time range", () => {
useAppStore.getState().setDuties([
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />);
expect(screen.getByText("Иванов")).toBeInTheDocument();
});
it("renders flip card with contact links when phone or username present", () => {
useAppStore.getState().setDuties([
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
phone: "+79991234567",
username: "alice_dev",
}),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-03" });
render(<DutyList />);
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,278 @@
/**
* Duty timeline list for the current month: dates, track line, flip cards.
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTranslation } from "@/i18n/use-translation";
import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
dateKeyToDDMM,
} from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { LoadingState } from "@/components/states/LoadingState";
import { DutyTimelineCard } from "./DutyTimelineCard";
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
const SCROLL_OVERLAP_PX = 14;
/**
* Skeleton placeholder for duty list (e.g. when loading a new month).
* Shows 4 card-shaped placeholders in timeline layout.
*/
export function DutyListSkeleton({ className }: { className?: string }) {
return (
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
<div
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
aria-hidden
/>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<Skeleton className="h-14 w-12 rounded" />
<span className="min-w-0" aria-hidden />
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
</div>
))}
</div>
);
}
export interface DutyListProps {
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
scrollMarginTop?: number;
className?: string;
}
/**
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
*/
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const { t } = useTranslation();
const listRef = useRef<HTMLDivElement>(null);
const { currentMonth, duties, dataForMonthKey } = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
duties: s.duties,
dataForMonthKey: s.dataForMonthKey,
}))
);
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
const hasDataForMonth = dataForMonthKey === monthKey;
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
const filteredList = duties.filter((d) => d.event_type === "duty");
const todayKey = localDateString(new Date());
const firstKey = localDateString(firstDayOfMonth(currentMonth));
const lastKey = localDateString(lastDayOfMonth(currentMonth));
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
const dateSet = new Set<string>();
filteredList.forEach((d) =>
dateSet.add(localDateString(new Date(d.start_at)))
);
if (showTodayInMonth) dateSet.add(todayKey);
const datesList = Array.from(dateSet).sort();
const byDate: Record<string, typeof filteredList> = {};
datesList.forEach((date) => {
byDate[date] = filteredList
.filter((d) => localDateString(new Date(d.start_at)) === date)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
});
return {
filtered: filteredList,
dates: datesList,
dutiesByDateKey: byDate,
};
}, [currentMonth, duties]);
const todayKey = localDateString(new Date());
const [now, setNow] = useState(() => new Date());
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
const scrolledForMonthRef = useRef<string | null>(null);
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
const prevMonthKeyRef = useRef<string>(monthKey);
useEffect(() => {
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
scrolledForMonthRef.current = null;
prevScrollMarginTopRef.current = scrollMarginTop;
}
if (prevMonthKeyRef.current !== monthKey) {
scrolledForMonthRef.current = null;
prevMonthKeyRef.current = monthKey;
}
const el = listRef.current;
if (!el) return;
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
const target = currentCard ?? todayBlock;
if (!target || scrolledForMonthRef.current === monthKey) return;
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
const scrollTo = () => {
const rect = target.getBoundingClientRect();
const scrollTop = window.scrollY + rect.top - effectiveMargin;
window.scrollTo({ top: scrollTop, behavior: "smooth" });
scrolledForMonthRef.current = monthKey;
};
requestAnimationFrame(() => {
requestAnimationFrame(scrollTo);
});
}, [filtered, dates.length, scrollMarginTop, monthKey]);
if (!hasDataForMonth) {
return (
<div aria-busy="true" className={className}>
<LoadingState asPlaceholder />
</div>
);
}
if (filtered.length === 0) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
</div>
);
}
return (
<div ref={listRef} className={className}>
<div className="duty-timeline relative text-[0.9rem]">
{/* Vertical track line */}
<div
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
aria-hidden
/>
{dates.map((date) => {
const isToday = date === todayKey;
const dateLabel = dateKeyToDDMM(date);
const dayDuties = dutiesByDateKey[date] ?? [];
return (
<div
key={date}
className="mb-0"
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
data-date={date}
data-today-block={isToday ? true : undefined}
>
{dayDuties.length > 0 ? (
dayDuties.map((duty) => {
const start = new Date(duty.start_at);
const end = new Date(duty.end_at);
const isCurrent = start <= now && now < end;
return (
<div
key={duty.id}
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<TimelineDateCell
dateLabel={dateLabel}
isToday={isToday}
/>
<span
className="min-w-0"
aria-hidden
/>
<div
className="min-w-0 overflow-hidden"
{...(isCurrent ? { "data-current-duty": true } : {})}
>
<DutyTimelineCard
duty={duty}
isCurrent={isCurrent}
isFlipped={flippedDutyId === duty.id}
onFlipChange={(flip) =>
setFlippedDutyId(flip ? duty.id : null)
}
/>
</div>
</div>
);
})
) : (
<div
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<TimelineDateCell
dateLabel={dateLabel}
isToday={isToday}
/>
<span className="min-w-0" aria-hidden />
<div className="min-w-0" />
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function TimelineDateCell({
dateLabel,
isToday,
}: {
dateLabel: string;
isToday: boolean;
}) {
const { t } = useTranslation();
return (
<span
className={cn(
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
)}
>
{isToday ? (
<>
<span className="duty-timeline-date-label text-today block leading-tight">
{t("duty.today")}
</span>
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
{dateLabel}
</span>
</>
) : (
dateLabel
)}
</span>
);
}

View File

@@ -0,0 +1,194 @@
/**
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
*/
"use client";
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "@/i18n/use-translation";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import type { DutyWithUser } from "@/types";
import { Phone, ArrowLeft } from "lucide-react";
export interface DutyTimelineCardProps {
duty: DutyWithUser;
isCurrent: boolean;
/** When provided, card is controlled: only one card can be flipped at a time (managed by parent). */
isFlipped?: boolean;
/** Called when user flips to contacts (true) or back (false). Used with isFlipped for controlled mode. */
onFlipChange?: (flipped: boolean) => void;
}
function buildTimeStr(duty: DutyWithUser): string {
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);
if (startLocal === endLocal) {
return `${startDDMM}, ${startTime} ${endTime}`;
}
return `${startDDMM} ${startTime} ${endDDMM} ${endTime}`;
}
const cardBase =
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
const borderByType = {
duty: "border-l-duty",
unavailable: "border-l-unavailable",
vacation: "border-l-vacation",
} as const;
/**
* Renders a single duty card. If duty has phone or username, wraps in a flip card
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
*/
export function DutyTimelineCard({
duty,
isCurrent,
isFlipped,
onFlipChange,
}: DutyTimelineCardProps) {
const { t } = useTranslation();
const [localFlipped, setLocalFlipped] = useState(false);
const frontBtnRef = useRef<HTMLButtonElement>(null);
const backBtnRef = useRef<HTMLButtonElement>(null);
const hasContacts = Boolean(
(duty.phone && String(duty.phone).trim()) ||
(duty.username && String(duty.username).trim())
);
const typeLabel = isCurrent
? t("duty.now_on_duty")
: t(`event_type.${duty.event_type || "duty"}`);
const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
const isControlled = onFlipChange != null;
const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
const handleFlipToBack = () => {
if (isControlled) {
onFlipChange?.(true);
} else {
setLocalFlipped(true);
}
setTimeout(() => backBtnRef.current?.focus(), 310);
};
const handleFlipToFront = () => {
if (isControlled) {
onFlipChange?.(false);
} else {
setLocalFlipped(false);
}
setTimeout(() => frontBtnRef.current?.focus(), 310);
};
if (!hasContacts) {
return (
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]"
)}
>
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
<span
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
</div>
);
}
return (
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
<div
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
style={{
transformStyle: "preserve-3d",
transform: flipped ? "rotateY(180deg)" : "none",
}}
>
{/* Front */}
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
"duty-flip-front"
)}
>
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
<span
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
<Button
ref={frontBtnRef}
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
aria-label={t("contact.show")}
onClick={handleFlipToBack}
>
<Phone className="size-[18px]" aria-hidden />
</Button>
</div>
{/* Back */}
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
"duty-flip-back"
)}
>
<span
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<div className="row-start-2 mt-1">
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="inline"
showLabels={false}
/>
</div>
<Button
ref={backBtnRef}
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
aria-label={t("contact.back")}
onClick={handleFlipToFront}
>
<ArrowLeft className="size-[18px]" aria-hidden />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Duty list and timeline components.
*/
export { DutyList } from "./DutyList";
export { DutyTimelineCard } from "./DutyTimelineCard";
export { DutyItem } from "./DutyItem";
export type { DutyListProps } from "./DutyList";
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
export type { DutyItemProps } from "./DutyItem";

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import {
init,
mountMiniAppSync,
mountThemeParamsSync,
bindThemeParamsCssVars,
} from "@telegram-apps/sdk-react";
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
/**
* Wraps the app with Telegram Mini App SDK initialization.
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
* and mounts the mini app. Does not call ready() here — the app calls
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
* has finished loading, so Telegram keeps its native loading animation until then.
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
* useTelegramTheme() in the app handles ongoing theme changes.
*/
export function TelegramProvider({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
const cleanup = init({ acceptCustomStyles: true });
if (mountThemeParamsSync.isAvailable()) {
mountThemeParamsSync();
}
if (bindThemeParamsCssVars.isAvailable()) {
bindThemeParamsCssVars();
}
fixSurfaceContrast();
void document.documentElement.offsetHeight;
if (mountMiniAppSync.isAvailable()) {
mountMiniAppSync();
}
return cleanup;
}, []);
return <>{children}</>;
}

View File

@@ -0,0 +1,24 @@
/**
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { AccessDenied } from "./AccessDenied";
import { resetAppStore } from "@/test/test-utils";
describe("AccessDenied", () => {
beforeEach(() => {
resetAppStore();
});
it("renders translated access denied message", () => {
render(<AccessDenied serverDetail={null} />);
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
});
it("appends serverDetail when provided", () => {
render(<AccessDenied serverDetail="Custom 403 message" />);
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,46 @@
/**
* Access denied state: message and optional server detail.
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
export interface AccessDeniedProps {
/** Optional detail from API 403 response, shown below the main message. */
serverDetail?: string | null;
/** Optional class for the container. */
className?: string;
}
/**
* Displays access denied message; optional second paragraph for server detail.
*/
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
const { t } = useTranslation();
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
return (
<div
className={cn(
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
className
)}
role="alert"
>
<p className="m-0 mb-2 font-semibold text-error">
{t("access_denied")}
</p>
{hasDetail && (
<p className="mt-2 m-0 text-sm text-muted">
{serverDetail}
</p>
)}
<p className="mt-2 m-0 text-sm text-muted">
{t("access_denied.hint")}
</p>
</div>
);
}

View File

@@ -0,0 +1,26 @@
/**
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorState } from "./ErrorState";
import { resetAppStore } from "@/test/test-utils";
describe("ErrorState", () => {
beforeEach(() => resetAppStore());
it("renders error message", () => {
render(<ErrorState message="Network error" onRetry={undefined} />);
expect(screen.getByText("Network error")).toBeInTheDocument();
});
it("renders Retry button when onRetry provided", () => {
const onRetry = vi.fn();
render(<ErrorState message="Fail" onRetry={onRetry} />);
const retry = screen.getByRole("button", { name: /retry|повторить/i });
expect(retry).toBeInTheDocument();
retry.click();
expect(onRetry).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
/**
* Error state: warning icon, message, and optional Retry button.
* Ported from webapp/js/ui.js showError and states.css .error.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ErrorStateProps {
/** Error message to display. If not provided, uses generic i18n message. */
message?: string | null;
/** Optional retry callback; when provided, a Retry button is shown. */
onRetry?: (() => void) | null;
/** Optional class for the container. */
className?: string;
}
/** Warning triangle icon 24×24 for error state. */
function ErrorIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("shrink-0 text-error", className)}
aria-hidden
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
/**
* Displays an error message with optional Retry button.
*/
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
const { t } = useTranslation();
const displayMessage =
message && String(message).trim() ? message : t("error_generic");
return (
<div
className={cn(
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
className
)}
role="alert"
>
<ErrorIcon />
<p className="m-0 text-sm font-medium">{displayMessage}</p>
{typeof onRetry === "function" && (
<Button
type="button"
variant="default"
size="sm"
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
onClick={onRetry}
>
{t("error.retry")}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
/**
* Unit tests for LoadingState. Ported from webapp/js/ui.test.js (loading).
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { LoadingState } from "./LoadingState";
import { resetAppStore } from "@/test/test-utils";
describe("LoadingState", () => {
beforeEach(() => resetAppStore());
it("renders loading text", () => {
render(<LoadingState />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
/**
* Loading state: spinner and translated "Loading…" text.
* Optionally wraps content in a container for calendar placeholder use.
* Ported from webapp CSS states.css .loading and index.html loading element.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
export interface LoadingStateProps {
/** Optional class for the container. */
className?: string;
/** If true, render a compact skeleton-style placeholder (e.g. for calendar area). */
asPlaceholder?: boolean;
}
/**
* Spinner icon matching original .loading__spinner (accent color, reduced-motion safe).
*/
function LoadingSpinner({ className }: { className?: string }) {
return (
<span
className={cn(
"block size-5 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none",
"animate-spin",
className
)}
aria-hidden
/>
);
}
/**
* Full loading view: flex center, spinner + "Loading…" text.
*/
export function LoadingState({ className, asPlaceholder }: LoadingStateProps) {
const { t } = useTranslation();
if (asPlaceholder) {
return (
<div
className={cn(
"flex min-h-[120px] items-center justify-center rounded-lg bg-muted/30",
className
)}
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<LoadingSpinner />
</div>
);
}
return (
<div
className={cn(
"flex items-center justify-center gap-2.5 py-3 text-center text-muted-foreground",
className
)}
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<LoadingSpinner />
<span className="loading__text">{t("loading")}</span>
</div>
);
}

View File

@@ -0,0 +1,7 @@
/**
* State components: loading, error, access denied.
*/
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { AccessDenied } from "./AccessDenied";

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -0,0 +1,163 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
forceMount,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
forceMount={forceMount}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
onCloseAnimationEnd,
onAnimationEnd,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
onCloseAnimationEnd?: () => void
}) {
const useForceMount = Boolean(onCloseAnimationEnd)
const handleAnimationEnd = React.useCallback(
(e: React.AnimationEvent<HTMLDivElement>) => {
onAnimationEnd?.(e)
if (e.currentTarget.getAttribute("data-state") === "closed") {
onCloseAnimationEnd?.()
}
},
[onAnimationEnd, onCloseAnimationEnd]
)
return (
<SheetPortal>
<SheetOverlay forceMount={useForceMount ? true : undefined} />
<SheetPrimitive.Content
data-slot="sheet-content"
forceMount={useForceMount ? true : undefined}
onAnimationEnd={handleAnimationEnd}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:duration-300",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-surface fill-surface" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,63 @@
/**
* Application initialization: language sync, access-denied logic, deep link routing.
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
*/
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getLang } from "@/i18n/messages";
import { useTranslation } from "@/i18n/use-translation";
import { RETRY_DELAY_MS } from "@/lib/constants";
export interface UseAppInitParams {
/** Whether the user is allowed (localhost or has valid initData). */
isAllowed: boolean;
/** Telegram Mini App start_param (e.g. "duty" for current duty deep link). */
startParam: string | undefined;
}
/**
* Syncs language from backend config, applies document lang/title, handles access denied
* when not allowed, and routes to current duty view when opened via startParam=duty.
*/
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
const setLang = useAppStore((s) => s.setLang);
const lang = useAppStore((s) => s.lang);
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
const setLoading = useAppStore((s) => s.setLoading);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const { t } = useTranslation();
// Sync lang from backend config (window.__DT_LANG).
useEffect(() => {
if (typeof window === "undefined") return;
setLang(getLang());
}, [setLang]);
// Apply lang to document (title and html lang) for accessibility and i18n.
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = lang;
document.title = t("app.title");
}, [lang, t]);
// When not allowed (no initData and not localhost), show access denied after delay.
useEffect(() => {
if (isAllowed) {
setAccessDenied(false);
return;
}
const id = setTimeout(() => {
setAccessDenied(true);
setLoading(false);
}, RETRY_DELAY_MS);
return () => clearTimeout(id);
}, [isAllowed, setAccessDenied, setLoading]);
// When opened via deep link startParam=duty, show current duty view first.
useEffect(() => {
if (startParam === "duty") setCurrentView("currentDuty");
}, [startParam, setCurrentView]);
}

View File

@@ -0,0 +1,29 @@
/**
* 60-second interval to refresh duty list when viewing the current month.
* Replaces state.todayRefreshInterval from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef } from "react";
const AUTO_REFRESH_INTERVAL_MS = 60000;
/**
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
*/
export function useAutoRefresh(
refresh: () => void,
isCurrentMonth: boolean
): void {
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
useEffect(() => {
if (!isCurrentMonth) return;
refreshRef.current();
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [isCurrentMonth]);
}

View File

@@ -0,0 +1,34 @@
/**
* Returns true when the given media query matches (e.g. min-width: 640px for desktop).
* Used to switch DayDetail between Popover (desktop) and Sheet (mobile).
*/
"use client";
import { useState, useEffect } from "react";
/**
* Match a media query string (e.g. "(min-width: 640px)").
* Returns undefined during SSR to avoid hydration mismatch; client gets the real value.
*/
export function useMediaQuery(query: string): boolean | undefined {
const [matches, setMatches] = useState<boolean | undefined>(() =>
typeof window === "undefined" ? undefined : window.matchMedia(query).matches
);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia(query);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mq.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, [query]);
return matches;
}
/** True when viewport is at least 640px (desktop). Undefined during SSR. */
export function useIsDesktop(): boolean | undefined {
return useMediaQuery("(min-width: 640px)");
}

View File

@@ -0,0 +1,212 @@
/**
* Fetches duties and calendar events for the current month. Handles loading, error,
* access denied, and retry after ACCESS_DENIED. Replaces loadMonth() from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "@/lib/api";
import {
firstDayOfMonth,
lastDayOfMonth,
getMonday,
localDateString,
dutyOverlapsLocalRange,
} from "@/lib/date-utils";
import { RETRY_AFTER_ACCESS_DENIED_MS, RETRY_AFTER_ERROR_MS, MAX_GENERAL_RETRIES } from "@/lib/constants";
import { logger } from "@/lib/logger";
import { translate } from "@/i18n/messages";
export interface UseMonthDataOptions {
/** Telegram init data string for API auth. When undefined, no fetch (unless isLocalhost). */
initDataRaw: string | undefined;
/** When true, fetch runs for the current month. When false, no fetch (e.g. access not allowed). */
enabled: boolean;
}
/**
* Fetches duties and calendar events for the displayed month when enabled.
* When pendingMonth is set (user clicked next/prev), loads that month without clearing
* current data; on success updates currentMonth and data in one batch (no empty-frame flicker).
* Cancels in-flight request when month changes or component unmounts.
* On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS.
* Returns retry() to manually trigger a reload.
*/
export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
const { initDataRaw, enabled } = options;
const currentMonth = useAppStore((s) => s.currentMonth);
const pendingMonth = useAppStore((s) => s.pendingMonth);
const lang = useAppStore((s) => s.lang);
const abortRef = useRef<AbortController | null>(null);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const initDataRetriedRef = useRef(false);
const generalRetryCountRef = useRef(0);
const mountedRef = useRef(true);
const optionsRef = useRef({ initDataRaw, enabled, lang });
optionsRef.current = { initDataRaw, enabled, lang };
const loadRef = useRef<() => void>(() => {});
useEffect(() => {
return () => {
mountedRef.current = false;
};
}, []);
const load = useCallback(() => {
const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current;
if (!enabledOpt) {
useAppStore.getState().batchUpdate({ pendingMonth: null });
return;
}
const initData = initDataRawOpt ?? "";
if (!initData && typeof window !== "undefined") {
const h = window.location.hostname;
if (h !== "localhost" && h !== "127.0.0.1" && h !== "") {
useAppStore.getState().batchUpdate({ pendingMonth: null });
return;
}
}
const store = useAppStore.getState();
const pending = store.pendingMonth;
const monthToLoad = pending ?? store.currentMonth;
const monthKey = `${monthToLoad.getFullYear()}-${String(monthToLoad.getMonth() + 1).padStart(2, "0")}`;
const dataForMonthKey = store.dataForMonthKey;
const isDeferredSwitch = pending !== null;
const isNewMonth = !isDeferredSwitch && dataForMonthKey !== monthKey;
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
const signal = abortRef.current.signal;
store.batchUpdate({
accessDenied: false,
accessDeniedDetail: null,
loading: true,
error: null,
...(isNewMonth
? { duties: [], calendarEvents: [], dataForMonthKey: null }
: {}),
});
const first = firstDayOfMonth(monthToLoad);
const start = getMonday(first);
const gridEnd = new Date(start);
gridEnd.setDate(gridEnd.getDate() + 41);
const from = localDateString(start);
const to = localDateString(gridEnd);
const run = async () => {
try {
logger.debug("Loading month", from, to);
const [duties, events] = await Promise.all([
fetchDuties(from, to, initData, langOpt, signal),
fetchCalendarEvents(from, to, initData, langOpt, signal),
]);
const last = lastDayOfMonth(monthToLoad);
const firstKey = localDateString(first);
const lastKey = localDateString(last);
const dutiesInMonth = duties.filter((d) =>
dutyOverlapsLocalRange(d, firstKey, lastKey)
);
const storeAfter = useAppStore.getState();
const switchedMonth =
storeAfter.currentMonth.getFullYear() !== monthToLoad.getFullYear() ||
storeAfter.currentMonth.getMonth() !== monthToLoad.getMonth();
useAppStore.getState().batchUpdate({
...(switchedMonth
? { currentMonth: new Date(monthToLoad.getFullYear(), monthToLoad.getMonth(), 1) }
: {}),
pendingMonth: null,
duties: dutiesInMonth,
calendarEvents: events,
dataForMonthKey: monthKey,
loading: false,
error: null,
});
} catch (e) {
if ((e as Error).name === "AbortError") {
useAppStore.getState().batchUpdate({
loading: false,
pendingMonth: null,
});
return;
}
if (e instanceof AccessDeniedError) {
logger.warn("Access denied in loadMonth", e.serverDetail);
useAppStore.getState().batchUpdate({
accessDenied: true,
accessDeniedDetail: e.serverDetail ?? null,
loading: false,
pendingMonth: null,
});
if (!initDataRetriedRef.current) {
initDataRetriedRef.current = true;
const timeoutId = setTimeout(() => {
if (mountedRef.current) loadRef.current();
}, RETRY_AFTER_ACCESS_DENIED_MS);
retryTimeoutRef.current = timeoutId;
}
return;
}
logger.error("Load month failed", e);
if (generalRetryCountRef.current < MAX_GENERAL_RETRIES && mountedRef.current) {
generalRetryCountRef.current++;
const timeoutId = setTimeout(() => {
if (mountedRef.current) loadRef.current();
}, RETRY_AFTER_ERROR_MS);
retryTimeoutRef.current = timeoutId;
return;
}
useAppStore.getState().batchUpdate({
error: translate(langOpt, "error_generic"),
loading: false,
pendingMonth: null,
});
}
};
run();
}, []);
loadRef.current = load;
useEffect(() => {
if (!enabled) return;
initDataRetriedRef.current = false;
generalRetryCountRef.current = 0;
load();
return () => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (abortRef.current) abortRef.current.abort();
abortRef.current = null;
};
}, [enabled, load, currentMonth, pendingMonth, lang, initDataRaw]);
useEffect(() => {
if (!enabled) return;
const handleVisibility = () => {
if (document.visibilityState !== "visible") return;
const { duties, loading: isLoading, error: hasError } = useAppStore.getState();
if (duties.length === 0 && !isLoading && !hasError) {
generalRetryCountRef.current = 0;
loadRef.current();
}
};
document.addEventListener("visibilitychange", handleVisibility);
return () => document.removeEventListener("visibilitychange", handleVisibility);
}, [enabled]);
return { retry: load };
}

View File

@@ -0,0 +1,44 @@
/**
* Toggles an "is-scrolled" class on the sticky element when the user has scrolled.
* Replaces bindStickyScrollShadow from webapp/js/main.js.
*/
"use client";
import { useEffect, useRef } from "react";
const IS_SCROLLED_CLASS = "is-scrolled";
/**
* Listens to window scroll and toggles the class "is-scrolled" on the given element
* when window.scrollY > 0. Uses passive scroll listener.
*/
export function useStickyScroll(
elementRef: React.RefObject<HTMLElement | null>
): void {
const rafRef = useRef<number | null>(null);
useEffect(() => {
const el = elementRef.current;
if (!el) return;
const update = () => {
rafRef.current = null;
const scrolled = typeof window !== "undefined" && window.scrollY > 0;
el.classList.toggle(IS_SCROLLED_CLASS, scrolled);
};
const handleScroll = () => {
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(update);
}
};
update();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
};
}, [elementRef]);
}

Some files were not shown because too many files have changed in this diff Show More