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

BIN
.coverage

Binary file not shown.

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

10
.gitignore vendored
View File

@@ -7,10 +7,18 @@ venv/
*.pyo
data/
*.db
.cursor/
# 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

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

View File

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

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

View File

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

View File

@@ -1 +0,0 @@

View File

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

View File

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

View File

@@ -1 +0,0 @@
duty_teller

View File

@@ -5,9 +5,10 @@ import re
from datetime import date, timedelta
import duty_teller.config as config
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
@@ -41,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."""
@@ -56,6 +67,102 @@ app.add_middleware(
)
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.
"""
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],
@@ -114,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:
@@ -126,7 +233,9 @@ def get_team_calendar_ical(
to_date = (today + timedelta(days=365 * 2)).strftime("%Y-%m-%d")
all_duties = get_duties(session, from_date=from_date, to_date=to_date)
duties_duty_only = [
(d, name) for d, name in all_duties if (d.event_type or "duty") == "duty"
(d, name)
for d, name, *_ in all_duties
if (d.event_type or "duty") == "duty"
]
ics_bytes = build_team_ics(duties_duty_only)
ics_calendar_cache.set(cache_key, ics_bytes)
@@ -153,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:
@@ -174,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.
@@ -190,7 +170,7 @@ def fetch_duties_response(
to_date: End date YYYY-MM-DD.
Returns:
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type).
List of DutyWithUser (id, user_id, start_at, end_at, full_name, event_type, phone, username).
"""
rows = get_duties(session, from_date=from_date, to_date=to_date)
return [
@@ -203,6 +183,8 @@ def fetch_duties_response(
event_type=(
duty.event_type if duty.event_type in DUTY_EVENT_TYPES else "duty"
),
phone=phone,
username=username,
)
for duty, full_name in rows
for duty, full_name, phone, username in rows
]

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,13 +56,65 @@ 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."""
bot_token: str
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]
@@ -66,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":
@@ -93,19 +156,34 @@ 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", ""
@@ -115,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")),
)
@@ -123,7 +202,9 @@ _settings = Settings.from_env()
BOT_TOKEN = _settings.bot_token
DATABASE_URL = _settings.database_url
BOT_USERNAME = _settings.bot_username
MINI_APP_BASE_URL = _settings.mini_app_base_url
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
HTTP_HOST = _settings.http_host
HTTP_PORT = _settings.http_port
ALLOWED_USERNAMES = _settings.allowed_usernames
@@ -137,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

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

View File

@@ -7,10 +7,12 @@ 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,
GroupDutyPin,
TrustedGroup,
CalendarSubscriptionToken,
Role,
)
@@ -200,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).
@@ -224,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
@@ -316,8 +326,8 @@ def get_duties(
session: Session,
from_date: str,
to_date: str,
) -> list[tuple[Duty, str]]:
"""Return duties overlapping the given date range with user full_name.
) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties overlapping the given date range with user full_name, phone, username.
Args:
session: DB session.
@@ -325,11 +335,11 @@ def get_duties(
to_date: End date YYYY-MM-DD.
Returns:
List of (Duty, full_name) tuples.
List of (Duty, full_name, phone, username) tuples.
"""
to_date_next = to_date_exclusive_iso(to_date)
q = (
session.query(Duty, User.full_name)
session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id)
.filter(Duty.start_at < to_date_next, Duty.end_at >= from_date)
)
@@ -342,7 +352,7 @@ def get_duties_for_user(
from_date: str,
to_date: str,
event_types: list[str] | None = None,
) -> list[tuple[Duty, str]]:
) -> list[tuple[Duty, str, str | None, str | None]]:
"""Return duties for one user overlapping the date range.
Optionally filter by event_type (e.g. "duty", "unavailable", "vacation").
@@ -356,7 +366,7 @@ def get_duties_for_user(
event_types: If not None, only return duties whose event_type is in this list.
Returns:
List of (Duty, full_name) tuples.
List of (Duty, full_name, phone, username) tuples.
"""
to_date_next = to_date_exclusive_iso(to_date)
filters = [
@@ -367,7 +377,7 @@ def get_duties_for_user(
if event_types is not None:
filters.append(Duty.event_type.in_(event_types))
q = (
session.query(Duty, User.full_name)
session.query(Duty, User.full_name, User.phone, User.username)
.join(User, Duty.user_id == User.id)
.filter(*filters)
)
@@ -446,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,
@@ -593,6 +605,71 @@ def get_all_group_duty_pin_chat_ids(session: Session) -> list[int]:
return [r[0] for r in rows]
def is_trusted_group(session: Session, chat_id: int) -> bool:
"""Check if the chat is in the trusted groups list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return (
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).first()
is not None
)
def add_trusted_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> TrustedGroup:
"""Add a group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
Returns:
Created TrustedGroup instance.
"""
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
record = TrustedGroup(
chat_id=chat_id,
added_by_user_id=added_by_user_id,
added_at=now_iso,
)
session.add(record)
session.commit()
session.refresh(record)
return record
def remove_trusted_group(session: Session, chat_id: int) -> None:
"""Remove a group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
session.query(TrustedGroup).filter(TrustedGroup.chat_id == chat_id).delete()
session.commit()
def get_all_trusted_group_ids(session: Session) -> list[int]:
"""Return all chat_ids that are trusted.
Args:
session: DB session.
Returns:
List of trusted chat ids.
"""
rows = session.query(TrustedGroup.chat_id).all()
return [r[0] for r in rows]
def set_user_phone(
session: Session, telegram_user_id: int, phone: str | None
) -> User | None:

View File

@@ -55,13 +55,16 @@ class DutyInDb(DutyBase):
class DutyWithUser(DutyInDb):
"""Duty with full_name and event_type for calendar display.
"""Duty with full_name, event_type, and optional contact fields for calendar display.
event_type: only these values are returned; unknown DB values are mapped to "duty" in the API.
phone and username are exposed only to authenticated Mini App users (role-gated).
"""
full_name: str
event_type: Literal["duty", "unavailable", "vacation"] = "duty"
phone: str | None = None
username: str | None = None
model_config = ConfigDict(from_attributes=True)

View File

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

View File

@@ -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"))
@@ -168,6 +173,8 @@ async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if await is_admin_async(update.effective_user.id):
lines.append(t(lang, "help.import_schedule"))
lines.append(t(lang, "help.set_role"))
lines.append(t(lang, "help.trust_group"))
lines.append(t(lang, "help.untrust_group"))
await update.message.reply_text("\n".join(lines))

View File

@@ -2,17 +2,19 @@
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
from telegram import Update
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatMemberStatus
from telegram.error import BadRequest, Forbidden
from telegram.ext import ChatMemberHandler, CommandHandler, ContextTypes
from duty_teller.db.session import session_scope
from duty_teller.i18n import get_lang, t
from duty_teller.handlers.common import is_admin_async
from duty_teller.services.group_duty_pin_service import (
get_duty_message_text,
get_message_id,
@@ -21,10 +23,17 @@ from duty_teller.services.group_duty_pin_service import (
save_pin,
delete_pin,
get_all_pin_chat_ids,
is_group_trusted,
trust_group,
untrust_group,
)
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
@@ -62,9 +71,70 @@ def _sync_get_message_id(chat_id: int) -> int | None:
return get_message_id(session, chat_id)
def _sync_is_trusted(chat_id: int) -> bool:
"""Check if the group is trusted (sync wrapper for handlers)."""
with session_scope(config.DATABASE_URL) as session:
return is_group_trusted(session, chat_id)
def _sync_trust_group(chat_id: int, added_by_user_id: int | None) -> bool:
"""Add group to trusted list. Returns True if already trusted (no-op)."""
with session_scope(config.DATABASE_URL) as session:
if is_group_trusted(session, chat_id):
return True
trust_group(session, chat_id, added_by_user_id)
return False
def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
"""Return inline keyboard with 'View contacts' URL button, or None if BOT_USERNAME not set.
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
Telegram returns Button_type_invalid. A plain URL button works everywhere.
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
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,
)
return InlineKeyboardMarkup([[button]])
def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
"""Remove group from trusted list.
Returns:
(was_trusted, message_id): was_trusted False if group was not in list;
message_id of pinned message if any (for cleanup), else None.
"""
with session_scope(config.DATABASE_URL) as session:
if not is_group_trusted(session, chat_id):
return (False, None)
message_id = get_message_id(session, chat_id)
delete_pin(session, chat_id)
untrust_group(session, chat_id)
return (True, message_id)
async def _schedule_next_update(
application, chat_id: int, when_utc: datetime | None
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")
@@ -75,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,
@@ -85,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),
@@ -102,17 +172,42 @@ async def _schedule_next_update(
async def _refresh_pin_for_chat(
context: ContextTypes.DEFAULT_TYPE, chat_id: int
) -> Literal["updated", "no_message", "failed"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id.
) -> Literal["updated", "no_message", "failed", "untrusted"]:
"""Refresh pinned duty message: send new message, unpin old, pin new, save new message_id, delete old.
Uses single DB session for message_id, text, next_shift_end (consolidated).
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
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()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
old_message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
await loop.run_in_executor(None, _sync_delete_pin, chat_id)
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if old_message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(
chat_id=chat_id, message_id=old_message_id
)
except (BadRequest, Forbidden):
pass
logger.info("Chat_id=%s no longer trusted, removed pin record and job", chat_id)
return "untrusted"
message_id, text, next_end = await loop.run_in_executor(
None,
lambda: _sync_get_pin_refresh_data(chat_id, config.DEFAULT_LANGUAGE),
@@ -120,28 +215,60 @@ async def _refresh_pin_for_chat(
if message_id is None:
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)
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)
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:
@@ -175,12 +302,27 @@ async def my_chat_member_handler(
ChatMemberStatus.BANNED,
):
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
lang = get_lang(update.effective_user)
try:
await context.bot.send_message(
chat_id=chat_id,
text=t(lang, "group.not_trusted"),
)
except (BadRequest, Forbidden):
pass
return
lang = get_lang(update.effective_user)
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(chat_id=chat_id, text=text)
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning("Failed to send duty message in chat_id=%s: %s", chat_id, e)
return
@@ -224,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))
@@ -244,9 +389,48 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
await update.message.reply_text(t(lang, "pin_duty.no_message"))
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message for pin_duty chat_id=%s: %s", chat_id, e
)
await update.message.reply_text(t(lang, "pin_duty.failed"))
return
pinned = False
try:
await context.bot.pin_chat_message(
chat_id=chat_id,
message_id=msg.message_id,
disable_notification=True,
)
pinned = True
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to pin message for pin_duty chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
if pinned:
await update.message.reply_text(t(lang, "pin_duty.pinned"))
else:
await update.message.reply_text(
t(lang, "pin_duty.could_not_pin_make_admin")
)
return
try:
await context.bot.pin_chat_message(
@@ -254,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)
@@ -270,13 +456,113 @@ async def refresh_pin_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
await update.message.reply_text(t(lang, "refresh_pin.group_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
trusted = await loop.run_in_executor(None, _sync_is_trusted, chat_id)
if not trusted:
await update.message.reply_text(t(lang, "group.not_trusted"))
return
result = await _refresh_pin_for_chat(context, chat_id)
await update.message.reply_text(t(lang, f"refresh_pin.{result}"))
async def trust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /trust_group: add current group to trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "trust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
already_trusted = await loop.run_in_executor(
None,
lambda: _sync_trust_group(
chat_id, update.effective_user.id if update.effective_user else None
),
)
if already_trusted:
await update.message.reply_text(t(lang, "trust_group.already_trusted"))
return
await update.message.reply_text(t(lang, "trust_group.added"))
message_id = await loop.run_in_executor(None, _sync_get_message_id, chat_id)
if message_id is None:
text = await loop.run_in_executor(
None, lambda: _get_duty_message_text_sync(lang)
)
try:
msg = await context.bot.send_message(
chat_id=chat_id,
text=text,
reply_markup=_get_contact_button_markup(lang),
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to send duty message after trust_group chat_id=%s: %s",
chat_id,
e,
)
return
try:
await context.bot.pin_chat_message(
chat_id=chat_id,
message_id=msg.message_id,
disable_notification=not config.DUTY_PIN_NOTIFY,
)
except (BadRequest, Forbidden) as e:
logger.warning(
"Failed to pin message after trust_group chat_id=%s: %s", chat_id, e
)
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
await _schedule_next_update(context.application, chat_id, next_end)
async def untrust_group_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /untrust_group: remove current group from trusted list (admin only)."""
if not update.message or not update.effective_chat or not update.effective_user:
return
chat = update.effective_chat
lang = get_lang(update.effective_user)
if chat.type not in ("group", "supergroup"):
await update.message.reply_text(t(lang, "untrust_group.group_only"))
return
if not await is_admin_async(update.effective_user.id):
await update.message.reply_text(t(lang, "import.admin_only"))
return
chat_id = chat.id
loop = asyncio.get_running_loop()
was_trusted, message_id = await loop.run_in_executor(
None, _sync_untrust_group, chat_id
)
if not was_trusted:
await update.message.reply_text(t(lang, "untrust_group.not_trusted"))
return
name = f"{JOB_NAME_PREFIX}{chat_id}"
if context.application.job_queue:
for job in context.application.job_queue.get_jobs_by_name(name):
job.schedule_removal()
if message_id is not None:
try:
await context.bot.unpin_chat_message(chat_id=chat_id)
except (BadRequest, Forbidden):
pass
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except (BadRequest, Forbidden):
pass
await update.message.reply_text(t(lang, "untrust_group.removed"))
group_duty_pin_handler = ChatMemberHandler(
my_chat_member_handler,
ChatMemberHandler.MY_CHAT_MEMBER,
)
pin_duty_handler = CommandHandler("pin_duty", pin_duty_cmd)
refresh_pin_handler = CommandHandler("refresh_pin", refresh_pin_cmd)
trust_group_handler = CommandHandler("trust_group", trust_group_cmd)
untrust_group_handler = CommandHandler("untrust_group", untrust_group_cmd)

View File

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

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

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,9 +15,21 @@ 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()."""
if not config.BOT_USERNAME:
me = await application.bot.get_me()
config.BOT_USERNAME = (me.username or "").lower()
logger.info("Resolved BOT_USERNAME from API: %s", config.BOT_USERNAME)
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
level=config.LOG_LEVEL,
)
logger = logging.getLogger(__name__)
@@ -60,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()
@@ -69,22 +102,37 @@ def main() -> None:
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build()
)
register_handlers(app)
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

@@ -13,6 +13,9 @@ from duty_teller.db.repository import (
save_group_duty_pin,
delete_group_duty_pin,
get_all_group_duty_pin_chat_ids,
is_trusted_group,
add_trusted_group,
remove_trusted_group,
)
from duty_teller.i18n import t
from duty_teller.utils.dates import parse_utc_iso
@@ -82,10 +85,6 @@ def format_duty_message(duty, user, tz_name: str, lang: str = "en") -> str:
f"🕐 {label} {time_range}",
f"👤 {user.full_name}",
]
if user.phone:
lines.append(f"📞 {user.phone}")
if user.username:
lines.append(f"@{user.username}")
return "\n".join(lines)
@@ -164,3 +163,39 @@ def get_all_pin_chat_ids(session: Session) -> list[int]:
List of chat ids.
"""
return get_all_group_duty_pin_chat_ids(session)
def is_group_trusted(session: Session, chat_id: int) -> bool:
"""Check if the group is in the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
Returns:
True if the group is trusted.
"""
return is_trusted_group(session, chat_id)
def trust_group(
session: Session, chat_id: int, added_by_user_id: int | None = None
) -> None:
"""Add the group to the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
added_by_user_id: Telegram user id of the admin who added the group (optional).
"""
add_trusted_group(session, chat_id, added_by_user_id)
def untrust_group(session: Session, chat_id: int) -> None:
"""Remove the group from the trusted list.
Args:
session: DB session.
chat_id: Telegram chat id.
"""
remove_trusted_group(session, chat_id)

View File

@@ -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:
@@ -71,3 +76,31 @@ class TestValidateDutyDates:
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "From after to message"
mock_t.assert_called_with("ru", "dates.from_after_to")
class TestFetchDutiesResponse:
"""Tests for fetch_duties_response (DutyWithUser list with phone, username)."""
def test_fetch_duties_response_includes_phone_and_username(self):
"""get_duties returns (Duty, full_name, phone, username); response has phone, username."""
from types import SimpleNamespace
from duty_teller.db.schemas import DutyWithUser
duty = SimpleNamespace(
id=1,
user_id=10,
start_at="2025-01-15T09:00:00Z",
end_at="2025-01-15T18:00:00Z",
event_type="duty",
)
rows = [(duty, "Alice", "+79001234567", "alice_dev")]
with patch.object(deps, "get_duties", return_value=rows):
result = deps.fetch_duties_response(
type("Session", (), {})(), "2025-01-01", "2025-01-31"
)
assert len(result) == 1
assert isinstance(result[0], DutyWithUser)
assert result[0].full_name == "Alice"
assert result[0].phone == "+79001234567"
assert result[0].username == "alice_dev"

View File

@@ -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)."""
@@ -254,7 +352,8 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
)
def fake_get_duties(session, from_date, to_date):
return [(fake_duty, "User A")]
# get_duties returns (Duty, full_name, phone, username) tuples.
return [(fake_duty, "User A", "+79001234567", "user_a")]
with patch("duty_teller.api.dependencies.get_duties", side_effect=fake_get_duties):
r = client.get(
@@ -266,23 +365,26 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
assert len(data) == 1
assert data[0]["event_type"] == "duty"
assert data[0]["full_name"] == "User A"
assert data[0].get("phone") == "+79001234567"
assert data[0].get("username") == "user_a"
def test_calendar_ical_team_404_invalid_token_format(client):
"""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()
@@ -311,7 +413,11 @@ def test_calendar_ical_team_200_only_duty_and_description(
end_at="2026-06-16T18:00:00Z",
event_type="vacation",
)
mock_get_duties.return_value = [(duty, "User A"), (non_duty, "User B")]
# get_duties returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [
(duty, "User A", None, None),
(non_duty, "User B", None, None),
]
mock_build_team_ics.return_value = b"BEGIN:VCALENDAR\r\nPRODID:Team\r\nVEVENT\r\nDESCRIPTION:User A\r\nEND:VCALENDAR"
token = "y" * 43
@@ -330,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")
@@ -343,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()
@@ -371,7 +475,8 @@ def test_calendar_ical_200_returns_only_that_users_duties(
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
mock_get_duties.return_value = [(duty, "User A")]
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [(duty, "User A", None, None)]
mock_build_ics.return_value = (
b"BEGIN:VCALENDAR\r\nVEVENT\r\n2026-06-15\r\nEND:VCALENDAR"
)
@@ -415,7 +520,8 @@ def test_calendar_ical_ignores_unknown_query_params(
end_at="2026-06-15T18:00:00Z",
event_type="duty",
)
mock_get_duties.return_value = [(duty, "User A")]
# get_duties_for_user returns (Duty, full_name, phone, username) tuples.
mock_get_duties.return_value = [(duty, "User A", None, None)]
mock_build_ics.return_value = b"BEGIN:VCALENDAR\r\nEND:VCALENDAR"
token = "z" * 43

View File

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

@@ -81,6 +81,7 @@ class TestFormatDutyMessage:
assert result == "No duty"
def test_with_duty_and_user_returns_formatted(self):
"""Formatted message includes time range and full name only; no contact info (phone/username)."""
duty = SimpleNamespace(
start_at="2025-01-15T09:00:00Z",
end_at="2025-01-15T18:00:00Z",
@@ -94,9 +95,10 @@ class TestFormatDutyMessage:
mock_t.side_effect = lambda lang, key: "Duty" if key == "duty.label" else ""
result = svc.format_duty_message(duty, user, "Europe/Moscow", "ru")
assert "Иван Иванов" in result
assert "+79001234567" in result or "79001234567" in result
assert "@ivan" in result
assert "Duty" in result
# Contact info is restricted to Mini App; not shown in pinned group message.
assert "+79001234567" not in result and "79001234567" not in result
assert "@ivan" not in result
class TestGetDutyMessageText:

View File

@@ -11,6 +11,13 @@ import duty_teller.config as config
from duty_teller.handlers import group_duty_pin as mod
@pytest.fixture(autouse=True)
def no_mini_app_url():
"""Ensure BOT_USERNAME is empty so duty messages are sent without contact button (reply_markup=None)."""
with patch.object(config, "BOT_USERNAME", ""):
yield
class TestSyncWrappers:
"""Tests for _get_duty_message_text_sync, _sync_save_pin, _sync_delete_pin, _sync_get_message_id, _get_all_pin_chat_ids_sync."""
@@ -76,6 +83,49 @@ class TestSyncWrappers:
# --- _schedule_next_update ---
def test_get_contact_button_markup_empty_username_returns_none():
"""_get_contact_button_markup: BOT_USERNAME empty -> returns None."""
with patch.object(config, "BOT_USERNAME", ""):
assert mod._get_contact_button_markup("en") is None
def test_get_contact_button_markup_returns_markup_when_username_set():
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
from telegram import InlineKeyboardMarkup
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
assert isinstance(result, InlineKeyboardMarkup)
assert len(result.inline_keyboard) == 1
assert len(result.inline_keyboard[0]) == 1
btn = result.inline_keyboard[0][0]
assert btn.text == "View contacts"
assert btn.url.startswith("https://t.me/")
assert "startapp=duty" in btn.url
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
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."""
@@ -127,7 +177,7 @@ async def test_schedule_next_update_when_utc_none_runs_once_with_retry_delay():
@pytest.mark.asyncio
async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, schedules next."""
"""update_group_pin: with message_id and text, sends new message, unpins, pins new, saves id, deletes old, schedules next."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
@@ -137,24 +187,70 @@ async def test_update_group_pin_sends_new_unpins_pins_saves_schedules_next():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(1, "Current duty", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=123, text="Current duty")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=123, text="Current duty", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=123)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=123, message_id=999, disable_notification=False
)
mock_save.assert_called_once_with(123, 999)
context.bot.delete_message.assert_called_once_with(chat_id=123, message_id=1)
@pytest.mark.asyncio
async def test_update_group_pin_delete_message_raises_bad_request_still_schedules():
"""update_group_pin: delete_message raises BadRequest -> save and schedule still done, log warning."""
new_msg = MagicMock()
new_msg.message_id = 999
context = MagicMock()
context.job = MagicMock()
context.job.data = {"chat_id": 123}
context.bot = MagicMock()
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock(
side_effect=BadRequest("Message to delete not found")
)
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", True):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod,
"_sync_get_pin_refresh_data",
return_value=(1, "Current duty", None),
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
mock_save.assert_called_once_with(123, 999)
mock_schedule.assert_called_once_with(context.application, 123, None)
mock_logger.warning.assert_called_once()
assert "Could not delete old pinned message" in mock_logger.warning.call_args[0][0]
@pytest.mark.asyncio
@@ -166,10 +262,11 @@ async def test_update_group_pin_no_message_id_skips():
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(None, "No duty", None)
):
await mod.update_group_pin(context)
context.bot.send_message.assert_not_called()
@@ -185,19 +282,22 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(mod, "_schedule_next_update", AsyncMock()) as mock_schedule:
await mod.update_group_pin(context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(2, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
await mod.update_group_pin(context)
context.bot.unpin_chat_message.assert_not_called()
context.bot.pin_chat_message.assert_not_called()
mock_schedule.assert_called_once_with(context.application, 111, None)
@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()
@@ -206,31 +306,70 @@ 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_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
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")
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):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(mod, "logger") as mock_logger:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=222, text="Text", reply_markup=None
)
mock_save.assert_not_called()
mock_logger.warning.assert_called_once()
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
mock_schedule.assert_called_once_with(context.application, 222, None)
@pytest.mark.asyncio
async def test_update_group_pin_duty_pin_notify_false_pins_silent():
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, schedule."""
"""update_group_pin: DUTY_PIN_NOTIFY False -> send new, unpin, pin new with disable_notification=True, save, delete old, schedule."""
new_msg = MagicMock()
new_msg.message_id = 777
context = MagicMock()
@@ -240,23 +379,28 @@ async def test_update_group_pin_duty_pin_notify_false_pins_silent():
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.unpin_chat_message = AsyncMock()
context.bot.pin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
with patch.object(config, "DUTY_PIN_NOTIFY", False):
with patch.object(
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(chat_id=333, text="Text")
mod, "_sync_get_pin_refresh_data", return_value=(4, "Text", None)
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch.object(mod, "_sync_save_pin") as mock_save:
await mod.update_group_pin(context)
context.bot.send_message.assert_called_once_with(
chat_id=333, text="Text", reply_markup=None
)
context.bot.unpin_chat_message.assert_called_once_with(chat_id=333)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=333, message_id=777, disable_notification=True
)
mock_save.assert_called_once_with(333, 777)
context.bot.delete_message.assert_called_once_with(chat_id=333, message_id=4)
mock_schedule.assert_called_once_with(context.application, 333, None)
@@ -282,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()
@@ -293,21 +437,28 @@ 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_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch.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")
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_replies_no_message():
"""pin_duty_cmd: no pin record (_sync_get_message_id -> None) -> reply pin_duty.no_message."""
async def test_pin_duty_cmd_untrusted_group_rejects():
"""pin_duty_cmd in untrusted group -> reply group.not_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
@@ -316,14 +467,150 @@ async def test_pin_duty_cmd_no_message_id_replies_no_message():
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message to pin"
mock_t.return_value = "Not authorized"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message to pin")
mock_t.assert_called_with("en", "pin_duty.no_message")
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_creates_sends_pins_saves_schedules_replies_pinned():
"""pin_duty_cmd: no pin record -> send_message, pin, save_pin, schedule, reply pinned."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 42
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Pinned"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=42, disable_notification=True
)
mock_save.assert_called_once_with(100, 42)
update.message.reply_text.assert_called_once_with("Pinned")
mock_t.assert_called_with("en", "pin_duty.pinned")
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_send_message_raises_replies_failed():
"""pin_duty_cmd: no pin record, send_message raises BadRequest -> reply pin_duty.failed."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock(side_effect=BadRequest("Chat not found"))
context.application = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_schedule_next_update", AsyncMock()
) as mock_schedule:
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Failed"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "pin_duty.failed")
mock_save.assert_not_called()
mock_schedule.assert_not_called()
@pytest.mark.asyncio
async def test_pin_duty_cmd_no_message_id_pin_raises_saves_and_replies_could_not_pin():
"""pin_duty_cmd: no pin record, pin_chat_message raises -> save pin, reply could_not_pin_make_admin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 43
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.pin_duty_cmd(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty", reply_markup=None
)
mock_save.assert_called_once_with(100, 43)
update.message.reply_text.assert_called_once_with("Make me admin to pin")
mock_t.assert_called_with("en", "pin_duty.could_not_pin_make_admin")
@pytest.mark.asyncio
@@ -343,10 +630,11 @@ async def test_pin_duty_cmd_pin_raises_replies_failed():
)
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_sync_get_message_id", return_value=5):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed to pin"
await mod.pin_duty_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed to pin")
mock_t.assert_called_with("en", "pin_duty.failed")
@@ -387,12 +675,13 @@ async def test_refresh_pin_cmd_group_updated_replies_updated():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="updated")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Updated"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Updated")
mock_t.assert_called_with("en", "refresh_pin.updated")
@@ -410,12 +699,13 @@ async def test_refresh_pin_cmd_group_no_message_replies_no_message():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="no_message")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "No message"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("No message")
mock_t.assert_called_with("en", "refresh_pin.no_message")
@@ -433,16 +723,42 @@ async def test_refresh_pin_cmd_group_edit_raises_replies_failed():
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock(return_value="failed")
):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Failed"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Failed")
mock_t.assert_called_with("en", "refresh_pin.failed")
@pytest.mark.asyncio
async def test_refresh_pin_cmd_untrusted_group_rejects():
"""refresh_pin_cmd in untrusted group -> reply group.not_trusted, _refresh_pin_for_chat not called."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(
mod, "_refresh_pin_for_chat", AsyncMock()
) as mock_refresh:
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.refresh_pin_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
mock_refresh.assert_not_called()
# --- my_chat_member_handler ---
@@ -491,12 +807,80 @@ async def test_my_chat_member_handler_bot_added_sends_pins_and_schedules():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty text"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Duty text")
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@pytest.mark.asyncio
async def test_my_chat_member_handler_untrusted_group_does_not_send_duty():
"""my_chat_member_handler: bot added to untrusted group -> send group.not_trusted only, no duty message/pin/schedule."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not authorized"
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(chat_id=200, text="Not authorized")
mock_t.assert_called_with("en", "group.not_trusted")
@pytest.mark.asyncio
async def test_my_chat_member_handler_trusted_group_sends_duty():
"""my_chat_member_handler: bot added to trusted group -> send duty, pin, schedule (same as test_my_chat_member_handler_bot_added_sends_pins_and_schedules)."""
update = _make_my_chat_member_update(
old_status=ChatMemberStatus.LEFT,
new_status=ChatMemberStatus.ADMINISTRATOR,
chat_id=200,
bot_id=999,
)
context = MagicMock()
context.bot = MagicMock()
context.bot.id = 999
context.bot.send_message = AsyncMock(return_value=MagicMock(message_id=42))
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
await mod.my_chat_member_handler(update, context)
context.bot.send_message.assert_called_once_with(
chat_id=200, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=200, message_id=42, disable_notification=True
)
@@ -522,13 +906,18 @@ async def test_my_chat_member_handler_pin_raises_sends_could_not_pin():
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
with patch.object(mod, "_sync_is_trusted", return_value=True):
with patch.object(mod, "_get_duty_message_text_sync", return_value="Duty"):
with patch.object(mod, "_sync_save_pin"):
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(mod, "_schedule_next_update", AsyncMock()):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Make me admin to pin"
await mod.my_chat_member_handler(update, context)
assert context.bot.send_message.call_count >= 2
pin_hint_calls = [
c
@@ -563,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=[])
@@ -577,5 +966,198 @@ 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 ---
@pytest.mark.asyncio
async def test_refresh_pin_for_chat_untrusted_removes_pin():
"""_refresh_pin_for_chat: when group not trusted -> delete_pin, remove job, unpin/delete message, return untrusted."""
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
mock_job = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch.object(mod, "_sync_is_trusted", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=11):
with patch.object(mod, "_sync_delete_pin") as mock_delete_pin:
result = await mod._refresh_pin_for_chat(context, 100)
assert result == "untrusted"
mock_delete_pin.assert_called_once_with(100)
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=11)
# --- trust_group_cmd / untrust_group_cmd ---
@pytest.mark.asyncio
async def test_trust_group_cmd_non_admin_rejects():
"""trust_group_cmd: non-admin -> reply import.admin_only."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=False)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Admin only"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Admin only")
mock_t.assert_called_with("en", "import.admin_only")
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_adds_group():
"""trust_group_cmd: admin in group, group not yet trusted -> _sync_trust_group, reply added, then send+pin if no pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
new_msg = MagicMock()
new_msg.message_id = 50
context.bot.send_message = AsyncMock(return_value=new_msg)
context.bot.pin_chat_message = AsyncMock()
context.application = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
context.application.job_queue.run_once = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=False):
with patch.object(mod, "_sync_get_message_id", return_value=None):
with patch.object(
mod, "_get_duty_message_text_sync", return_value="Duty text"
):
with patch.object(mod, "_sync_save_pin") as mock_save:
with patch.object(
mod, "_get_next_shift_end_sync", return_value=None
):
with patch.object(
mod, "_schedule_next_update", AsyncMock()
):
with patch(
"duty_teller.handlers.group_duty_pin.t"
) as mock_t:
mock_t.return_value = "Added"
with patch.object(
config, "DUTY_PIN_NOTIFY", False
):
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_any_call("Added")
mock_t.assert_any_call("en", "trust_group.added")
context.bot.send_message.assert_called_once_with(
chat_id=100, text="Duty text", reply_markup=None
)
context.bot.pin_chat_message.assert_called_once_with(
chat_id=100, message_id=50, disable_notification=True
)
mock_save.assert_called_once_with(100, 50)
@pytest.mark.asyncio
async def test_trust_group_cmd_admin_already_trusted_replies_already_trusted():
"""trust_group_cmd: admin, group already trusted -> reply already_trusted, no send/pin."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.send_message = AsyncMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_trust_group", return_value=True):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Already trusted"
await mod.trust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Already trusted")
mock_t.assert_called_with("en", "trust_group.already_trusted")
context.bot.send_message.assert_not_called()
@pytest.mark.asyncio
async def test_untrust_group_cmd_removes_group():
"""untrust_group_cmd: admin, trusted group with pin -> remove from trusted, delete pin, remove job, unpin/delete message, reply removed."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
update.effective_user.id = 111
context = MagicMock()
context.bot = MagicMock()
context.bot.unpin_chat_message = AsyncMock()
context.bot.delete_message = AsyncMock()
context.application = MagicMock()
mock_job = MagicMock()
context.application.job_queue = MagicMock()
context.application.job_queue.get_jobs_by_name = MagicMock(return_value=[mock_job])
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(True, 99)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Removed"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Removed")
mock_t.assert_called_with("en", "untrust_group.removed")
context.application.job_queue.get_jobs_by_name.assert_called_once_with(
"duty_pin_100"
)
mock_job.schedule_removal.assert_called_once()
context.bot.unpin_chat_message.assert_called_once_with(chat_id=100)
context.bot.delete_message.assert_called_once_with(chat_id=100, message_id=99)
@pytest.mark.asyncio
async def test_untrust_group_cmd_not_trusted_replies_not_trusted():
"""untrust_group_cmd: group not in trusted list -> reply not_trusted."""
update = MagicMock()
update.message = MagicMock()
update.message.reply_text = AsyncMock()
update.effective_chat = MagicMock()
update.effective_chat.type = "group"
update.effective_chat.id = 100
update.effective_user = MagicMock()
context = MagicMock()
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
with patch.object(mod, "is_admin_async", AsyncMock(return_value=True)):
with patch.object(mod, "_sync_untrust_group", return_value=(False, None)):
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
mock_t.return_value = "Not trusted"
await mod.untrust_group_cmd(update, context)
update.message.reply_text.assert_called_once_with("Not trusted")
mock_t.assert_called_with("en", "untrust_group.not_trusted")

View File

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

@@ -77,7 +77,7 @@ def test_import_creates_users_and_duties(db_url):
assert "2026-02-16T06:00:00Z" in starts
assert "2026-02-17T06:00:00Z" in starts
assert "2026-02-18T06:00:00Z" in starts
for d, _ in duties:
for d, *_ in duties:
assert d.event_type == "duty"

View File

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

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

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>
);
}

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