59 Commits
v1.2.0 ... main

Author SHA1 Message Date
0ee77ee5c1 chore(release): v2.1.3
All checks were successful
CI / lint-and-test (push) Successful in 1m7s
Docker Build and Release / build-and-push (push) Successful in 1m30s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-07 00:40:47 +03:00
3f34c7951f feat: enhance CalendarDay component with holiday outline styling
All checks were successful
CI / lint-and-test (push) Successful in 1m4s
- Added a new CSS variable for today's holiday outline to improve visual distinction on the calendar.
- Updated the CalendarDay component to apply the holiday outline styling when the day is marked as today and has associated event summaries.
- Enhanced unit tests to verify the correct application of styles for today and holiday events, ensuring proper rendering and user experience.
2026-03-07 00:35:26 +03:00
dc87b3ad97 feat: update copy functionality in ContactLinks component
- Modified the copy button behavior to display "Copied" via button aria-label instead of a tooltip after a successful copy action.
- Enhanced the component to revert the copy button icon when switching between phone number and Telegram username copy actions.
- Updated tests to reflect changes in copy button functionality and ensure proper rendering based on user interactions.
2026-03-07 00:11:36 +03:00
7cd00893ad chore(release): v2.1.2
All checks were successful
CI / lint-and-test (push) Successful in 1m5s
Docker Build and Release / build-and-push (push) Successful in 50s
Docker Build and Release / release (push) Successful in 9s
2026-03-06 20:12:55 +03:00
95d3af4930 fix: improve styling in ContactLinks component
All checks were successful
CI / lint-and-test (push) Successful in 1m4s
- Updated the row class in the ContactLinks component to include a shadow effect, enhancing visual depth and user experience.
- Ensured consistent styling across light and dark themes by adjusting background properties.
2026-03-06 19:11:52 +03:00
24d6ecbedb feat: add copy functionality for phone and Telegram username in ContactLinks component
- Implemented copy buttons for phone number and Telegram username in the ContactLinks component, enhancing user interaction.
- Integrated tooltip feedback to indicate successful copy actions.
- Updated tests to cover new copy functionality and ensure proper rendering of copy buttons based on props.
- Added localization support for new copy-related strings in the i18n messages.
2026-03-06 18:55:56 +03:00
34001d22d9 chore(release): v2.1.1
All checks were successful
CI / lint-and-test (push) Successful in 1m4s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-06 18:13:02 +03:00
4d09c8641c test: enhance admin API tests with database session mocking
All checks were successful
CI / lint-and-test (push) Successful in 1m6s
- Introduced a mock database session for admin API tests to prevent real database access during CI.
- Updated test cases for the `/api/admin/me` endpoint to ensure consistent behavior without a real database.
- Improved error handling and clarity in tests by utilizing dependency overrides for session management.
- Ensured proper cleanup of dependency overrides after tests to maintain test isolation.
2026-03-06 18:08:45 +03:00
172d145f0e refactor: streamline code formatting in API and tests
Some checks failed
CI / lint-and-test (push) Failing after 1m17s
- Consolidated function call formatting in `admin_reassign_duty` for improved readability.
- Enhanced test cases by standardizing header formatting in API requests, ensuring consistency across tests.
- Improved overall code clarity and maintainability by adhering to established coding style guidelines.
2026-03-06 18:01:08 +03:00
45c65e3025 fix: update error handling in CurrentDutyView component
- Replaced direct translation function with a new `translate` utility for error messages, improving localization support.
- Removed unnecessary loading state call during duty loading to streamline the process.
- Updated dependencies in the loadTodayDuties function to enhance clarity and maintainability.
2026-03-06 17:59:32 +03:00
fa22976e75 feat: enhance Mini App design guidelines and refactor layout components
- Updated Mini App design guidelines to include detailed instructions on UI changes, accessibility rules, and verification processes.
- Refactored multiple components to utilize `MiniAppScreen` and `MiniAppScreenContent` for consistent layout structure across the application.
- Improved error handling in `GlobalError` and `NotFound` components by integrating new layout components for better user experience.
- Introduced new hooks for admin functionality, streamlining access checks and data loading processes.
- Enhanced documentation to reflect changes in design policies and component usage, ensuring clarity for future development.
2026-03-06 17:51:33 +03:00
43cd3bbd7d refactor: improve layout structure and consistency across components
- Refactored layout structure in multiple components to enhance consistency and maintainability by introducing outer and inner wrapper classes.
- Updated the `MonthNavHeader` component for shared month navigation functionality, improving code reuse.
- Adjusted padding and margin properties in various components to ensure a cohesive design and better responsiveness.
- Removed unnecessary padding from certain elements to streamline the layout and improve visual clarity.
2026-03-06 17:19:31 +03:00
26a9443e1b fix: update content-safe class padding for improved layout consistency
- Adjusted padding-left and padding-right in the .content-safe class to ensure a minimum horizontal padding of 0.75rem, enhancing layout stability across devices.
- Updated comments to clarify the purpose of safe area insets in relation to Telegram UI.
2026-03-06 17:01:35 +03:00
40e2b5adc4 feat: enhance theme handling and layout components for Telegram Mini App
- Updated theme resolution logic to utilize a shared inline script for consistent theme application across routes.
- Introduced `AppShell` and `ReadyGate` components to manage app readiness and theme synchronization, improving user experience.
- Enhanced `GlobalError` and `NotFound` pages with a unified full-screen layout for better accessibility and visual consistency.
- Refactored CSS to implement safe area insets for sticky headers and content safety, ensuring proper layout on various devices.
- Added unit tests for new functionality and improved existing tests for better coverage and reliability.
2026-03-06 16:48:24 +03:00
76bff6dc05 feat: enhance ReassignSheet component with dynamic title and improved accessibility
- Added logic to dynamically set the sheet title based on the selected duty date, improving user context.
- Updated the description to be visually hidden while maintaining accessibility for screen readers.
- Refactored user selection display to enhance clarity and organization, ensuring a better user experience.
2026-03-06 14:32:04 +03:00
6da6c87d3c feat: enhance admin and contact components with new functionality
- Updated `AdminPage` to conditionally display duty reassignment instructions based on visible groups, improving user guidance.
- Refactored `AdminDutyList` to streamline the display of duties, enhancing visual clarity and organization.
- Introduced `openPhoneLink` and `triggerHapticLight` functions in `ContactLinks` for improved phone link interaction and haptic feedback.
- Added unit tests for `openPhoneLink` to ensure correct functionality and handling of various phone number formats.
- Enhanced existing tests for `ContactLinks` to verify new phone link behavior, ensuring robust testing coverage.
2026-03-06 13:26:04 +03:00
02a586a1c5 feat: enhance admin page and testing functionality
- Updated admin page to include navigation buttons for month selection, improving user experience.
- Refactored `AdminDutyList` to group duties by date, enhancing the display and organization of duties.
- Improved error handling in `ReassignSheet` by using i18n keys for error messages, ensuring better localization support.
- Enhanced tests for admin page and components to reflect recent changes, ensuring accuracy in functionality and accessibility.
- Added event dispatch for configuration loading in the app configuration, improving integration with the Telegram Mini App.
2026-03-06 12:04:16 +03:00
53a899ea26 feat: enhance testing and admin page functionality
- Mocked `useRouter` from `next/navigation` in tests to improve routing behavior during testing.
- Updated admin page tests to reflect changes in title display and removed unnecessary back link check.
- Refactored admin page header to improve accessibility and layout, displaying month and year more clearly.
- Removed unused imports and components to streamline code and enhance maintainability.
2026-03-06 11:03:46 +03:00
a3152a4545 feat: enhance admin page functionality with new components and hooks
- Added `AdminDutyList` and `ReassignSheet` components for improved duty management in the admin panel.
- Introduced `useAdminPage` hook to encapsulate admin-related logic, including user and duty loading, and reassign functionality.
- Updated `frontend.mdc` documentation to reflect new admin components and their usage.
- Improved error handling for API responses, particularly for access denied scenarios.
- Refactored admin page to utilize new components, streamlining the UI and enhancing maintainability.
2026-03-06 10:17:28 +03:00
c390a4dd6e feat: implement admin panel functionality in Mini App
- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties.
- Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data.
- Updated documentation to include Mini App design guidelines and admin panel functionalities.
- Enhanced tests for admin API to ensure proper access control and functionality.
- Improved error handling and localization for admin actions.
2026-03-06 09:57:26 +03:00
68b1884b73 chore(release): v2.0.6
All checks were successful
CI / lint-and-test (push) Successful in 1m2s
Docker Build and Release / build-and-push (push) Successful in 1m1s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 22:12:22 +03:00
fb786c4c3a refactor: remove haptic feedback triggers from calendar and duty components
- Eliminated triggerHapticLight calls from CalendarPage, CalendarDay, DayDetail, and DutyTimelineCard components to streamline user interaction.
- This change focuses on improving performance and reducing unnecessary feedback in the user interface.
2026-03-04 22:11:07 +03:00
07e22079ee feat: enhance CSS and components for Telegram Mini App performance
- Updated CSS to utilize viewport variables for safe area insets and stable height, improving layout consistency across devices.
- Introduced haptic feedback triggers in various components to enhance user interaction, mimicking native Telegram behavior.
- Added functionality to detect Android performance class, minimizing animations on low-performance devices for better user experience.
- Refactored components to incorporate new CSS classes for content safety and improved responsiveness.
2026-03-04 19:19:14 +03:00
13aba85e28 chore(release): v2.0.4
All checks were successful
CI / lint-and-test (push) Successful in 1m2s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 18:39:37 +03:00
8ad8dffd0a feat: implement post-init function for application startup tasks
- Added _post_init function to run startup tasks, including restoring group pin jobs and resolving bot username.
- Updated main function to utilize the new _post_init for improved application initialization process.
2026-03-04 18:38:20 +03:00
6d087d1b26 feat: prevent default focus behavior on DayDetail component
- Added onOpenAutoFocus handler to DayDetail component to prevent default focus behavior when the overlay opens.
- This enhancement improves user experience by maintaining focus control during component interactions.
2026-03-04 18:18:15 +03:00
94545dc8c3 feat: add overlay class support to SheetContent and DayDetail components
- Introduced overlayClassName prop to SheetContent for customizable overlay styling.
- Updated DayDetail component to utilize the new overlayClassName for enhanced visual effects.
- Improved user experience by allowing dynamic styling of the overlay during component rendering.
2026-03-04 18:03:35 +03:00
33359f589a feat: implement AccessDeniedScreen and enhance error handling
- Introduced AccessDeniedScreen component for improved user experience when access is denied, replacing the previous AccessDenied component.
- Updated CurrentDutyView and CalendarPage to handle access denied scenarios, displaying the new screen appropriately.
- Enhanced tests for CurrentDutyView and AccessDeniedScreen to ensure correct rendering and functionality under access denied conditions.
- Refactored localization messages to include new labels for access denied scenarios in both English and Russian.
2026-03-04 17:51:30 +03:00
3244fbe505 chore(release): v2.0.3
All checks were successful
CI / lint-and-test (push) Successful in 1m3s
Docker Build and Release / build-and-push (push) Successful in 51s
Docker Build and Release / release (push) Successful in 15s
Made-with: Cursor
2026-03-04 11:24:42 +03:00
d99912b080 feat: enhance CurrentDutyView with new functionality and improved tests
- Added a button to open the calendar in the no-duty view, triggering the onBack function.
- Implemented tests for the new button functionality and error handling in the CurrentDutyView component.
- Updated localization messages to include the new "Open calendar" label in both English and Russian.
- Refactored layout and styling for better user experience and accessibility.
2026-03-04 11:22:45 +03:00
6adec62b5f feat: enhance CurrentDutyView with loading skeletons and improved localization
- Added Skeleton components to CurrentDutyView for better loading state representation.
- Updated localization messages to include new labels for remaining time display.
- Refactored remaining time display logic for clarity and improved user experience.
2026-03-04 11:16:07 +03:00
a8d4afb101 refactor: improve layout and styling in CalendarHeader component
- Adjusted the layout of the CalendarHeader to enhance visual hierarchy by separating the year and month display.
- Updated CSS classes for better alignment and spacing, ensuring a more polished user interface.
- Enhanced accessibility by maintaining aria attributes for live updates.
2026-03-04 11:01:17 +03:00
106e42a81d chore(release): v2.0.2
All checks were successful
CI / lint-and-test (push) Successful in 1m6s
Docker Build and Release / build-and-push (push) Successful in 50s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 10:22:10 +03:00
3c4c28a1ac feat: add project release and run tests skills documentation
- Introduced SKILL.md files for project release and running tests in the duty-teller project.
- Documented the project release workflow, including steps for updating CHANGELOG, committing changes, and tagging releases.
- Provided instructions for running backend (pytest) and frontend (Vitest) tests, including prerequisites and failure handling.
- Enhanced user guidance for verifying changes and ensuring test coverage.
2026-03-04 10:18:43 +03:00
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
223 changed files with 26318 additions and 7214 deletions

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

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

View File

@@ -0,0 +1,70 @@
---
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` |
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
| 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()` for React UI; `getLang()/translate()` only for early bootstrap or non-React boundaries.
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`.
- **Platform boundary:** Use `src/hooks/telegram/*` adapters for Back/Settings/Close/swipe/closing behavior instead of direct SDK control calls in feature components.
- **Screen shells:** Reuse `src/components/layout/MiniAppScreen.tsx` wrappers for route and full-screen states.
## 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.
## Design guideline
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
- Keep all user-facing strings and `aria-label`/`sr-only` text localized.
- Follow Telegram interaction policy from the design guideline: vertical swipes enabled by default, closing confirmation only for stateful flows.
Use the checklist in the design doc when introducing new screens or components.
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");
});
});
```

View File

@@ -0,0 +1,55 @@
---
name: project-release
description: Performs a project release by updating CHANGELOG for the new version, committing all changes, and pushing a version tag. Use when the user asks to release, cut a release, publish a version, or to update changelog and push a tag.
---
# Project Release
Release workflow for duty-teller: update changelog, commit, tag, and push. Triggers Gitea Actions (Docker build) on `v*` tags.
## Prerequisites
- Decide the **new version** (e.g. `2.1.0`). Use [Semantic Versioning](https://semver.org/).
- Ensure `CHANGELOG.md` has entries under `## [Unreleased]` (or add a short note like "No changes" if intentional).
## Steps
### 1. Update CHANGELOG.md
- Replace the `## [Unreleased]` section with a dated release section:
- `## [X.Y.Z] - YYYY-MM-DD` (use today's date in `YYYY-MM-DD`).
- Leave a new empty `## [Unreleased]` section after it (for future edits).
- At the bottom of the file, add the comparison link for the new version:
- `[X.Y.Z]: https://github.com/your-org/duty-teller/releases/tag/vX.Y.Z`
- (Replace `your-org/duty-teller` with the real repo URL if different.)
- Update the `[Unreleased]` link to compare against this release, e.g.:
- `[Unreleased]: https://github.com/your-org/duty-teller/compare/vX.Y.Z...HEAD`
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); keep existing subsections (Added, Changed, Security, etc.) under the new version.
### 2. Bump version in pyproject.toml (optional)
- Set `version = "X.Y.Z"` in `[project]` so it matches the release. Skip if the project does not sync version here.
### 3. Commit and tag
- Stage all changes: `git add -A`
- Commit with Conventional Commits: `git commit -m "chore(release): vX.Y.Z"`
- Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
- Push branch: `git push origin main` (or current branch)
- Push tag: `git push origin vX.Y.Z`
Pushing the `v*` tag triggers `.gitea/workflows/docker-build.yml` (Docker build and release).
## Checklist
- [ ] CHANGELOG: `[Unreleased]``[X.Y.Z] - YYYY-MM-DD`, new empty `[Unreleased]`, links at bottom updated
- [ ] pyproject.toml version set to X.Y.Z (if used)
- [ ] `git add -A` && `git commit -m "chore(release): vX.Y.Z"`
- [ ] `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
- [ ] `git push origin main` && `git push origin vX.Y.Z`
## Notes
- Do not push tags from unreleased or uncommitted changelog.
- If the repo URL in CHANGELOG links is a placeholder, keep it or ask the user for the correct base URL.

View File

@@ -0,0 +1,60 @@
---
name: run-tests
description: Runs backend (pytest) and frontend (Vitest) tests for the duty-teller project. Use when the user asks to run tests, verify changes, or run pytest/vitest.
---
# Run tests
## When to use
- User asks to "run tests", "run the test suite", or "verify tests pass".
- After making code changes and user wants to confirm nothing is broken.
- User explicitly asks for backend tests (pytest) or frontend tests (vitest/npm test).
## Backend tests (Python)
From the **repository root**:
```bash
pytest
```
If imports fail, set `PYTHONPATH`:
```bash
PYTHONPATH=. pytest
```
- Config: `pyproject.toml``[tool.pytest.ini_options]` (coverage on `duty_teller`, 80% gate, asyncio mode).
- Tests live in `tests/`.
## Frontend tests (Next.js / Vitest)
From the **repository root**:
```bash
cd webapp-next && npm test
```
- Runner: Vitest (`vitest run`); env: jsdom; React Testing Library.
- Config: `webapp-next/vitest.config.ts`; setup: `webapp-next/src/test/setup.ts`.
## Running both
To run backend and frontend tests in sequence:
```bash
pytest && (cd webapp-next && npm test)
```
If the user did not specify "backend only" or "frontend only", run both and report results for each.
## Scope
- **Single file or dir:** `pytest path/to/test_file.py` or `pytest path/to/test_dir/`. For frontend, use Vitests path args as per its docs (e.g. under `webapp-next/`).
- **Verbosity:** Use `pytest -v` if the user wants more detail.
## Failures
- Do not send raw exception strings from tests to the user; summarize failures and point to failing test names/locations.
- If pytest fails with import errors, suggest `PYTHONPATH=. pytest` and ensure the venv is activated and dev deps are installed (`pip install -r requirements-dev.txt` or `pip install -e ".[dev]"`).

5
.cursor/worktrees.json Normal file
View File

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

View File

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

View File

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

11
.gitignore vendored
View File

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

54
AGENTS.md Normal file
View File

@@ -0,0 +1,54 @@
# 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/`) |
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
| 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).
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
- 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.
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases, use shared Mini App screen shells, keep Telegram SDK access behind `src/hooks/telegram/*`, apply safe-area/content-safe-area and accessibility rules, and run the Mini App verification matrix (light/dark, iOS/Android safe area, low-perf Android, deep links, admin flow, fallback states).
- **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,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.3] - 2025-03-07
(No changes documented; release for version sync.)
## [2.1.2] - 2025-03-06
(No changes documented; release for version sync.)
## [2.1.1] - 2025-03-06
(No changes documented; release for version sync.)
## [2.0.6] - 2025-03-04
(No changes documented; release for version sync.)
## [2.0.4] - 2025-03-04
(No changes documented; release for version sync.)
## [2.0.3] - 2025-03-04
(No changes documented; release for version sync.)
## [2.0.2] - 2025-03-04
(No changes documented; release for version sync.)
## [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 +72,13 @@ 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.
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.3...HEAD
[2.1.3]: https://github.com/your-org/duty-teller/releases/tag/v2.1.3
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->

View File

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

View File

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

View File

@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
- `main.py` Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
- `duty_teller/` Main package (install with `pip install -e .`). Contains:
- `config.py` Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
- `api/` FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
- `db/` SQLAlchemy models, session (`session_scope`), repository, schemas.
- `handlers/` Telegram command and chat handlers; register via `register_handlers(app)`.
- `i18n/` Translations and language detection (ru/en); used by handlers and API.
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
- `utils/` Shared date, user, and handover helpers.
- `importers/` Duty-schedule JSON parser.
- `alembic/` Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
- `webapp/` Miniapp UI (calendar, duty list); served at `/app`.
- `webapp-next/` Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
- `tests/` Tests; `helpers.py` provides `make_init_data` for auth tests.
- `pyproject.toml` Installable package (`pip install -e .`).
@@ -151,6 +151,7 @@ Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config`
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
- **Documentation:** Project documentation is in English; see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
## Logs and rotation

View File

@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
## Components
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
- **Miniapp → API**
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
- **Admin panel (Mini App)**
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
- **Import**
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()``DutyScheduleResult``duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).

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

@@ -6,8 +6,11 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
- [Architecture](architecture.md) — Components, data flow, package relationships.
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
- [Import format](import-format.md) — Duty-schedule JSON format and example.
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
- [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.

263
docs/miniapp-design.md Normal file
View File

@@ -0,0 +1,263 @@
# Mini App Design Guideline
This document defines the design rules for the Duty Teller Mini App (Next.js frontend in `webapp-next/`). It aligns with [Telegrams official Mini App design guidelines](https://core.telegram.org/bots/webapps#designing-mini-apps) (Color Schemes and Design Guidelines) and codifies the current implementation (calendar, duty list, admin) as the reference for new screens and components.
---
## 1. Telegram design principles
Telegrams guidelines state:
- **Mobile-first:** All elements must be responsive and designed for mobile first.
- **Consistency:** Interactive elements should match the style, behaviour, and intent of existing Telegram UI components.
- **Performance:** Animations should be smooth, ideally 60fps.
- **Accessibility:** Inputs and images must have labels.
- **Theme:** The app must use the dynamic theme-based colors provided by the API (Day/Night and custom themes).
- **Safe areas:** The interface must respect the **safe area** and **content safe area** so content does not overlap system or Telegram UI, especially in fullscreen.
- **Android:** On Android, use the extra User-Agent data (e.g. performance class) and reduce animations and effects on low-performance devices for smooth operation.
**Color schemes:** Mini Apps receive the users current **theme** in real time. Use the `ThemeParams` object and the CSS variables Telegram exposes (e.g. `--tg-theme-bg-color`, `--tg-theme-text-color`) so the UI adapts when the user switches Day/Night or custom themes.
---
## 2. Theme and colors
### 2.1 Sources
Theme is resolved in this order:
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the shared inline script from `webapp-next/src/lib/theme-bootstrap-script.ts`, used in layout and global-error).
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via **TelegramProvider** (theme sync is provider-owned in `ThemeSync` / `useTelegramTheme`), so every route (/, /admin, not-found, error) receives live theme updates.
The inline script maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The provider sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
### 2.2 Mapping (ThemeParams → app tokens)
In `webapp-next/src/app/globals.css`, `:root` and `[data-theme="light"]` / `[data-theme="dark"]` map Telegrams ThemeParams to internal design tokens:
| Telegram ThemeParam (CSS var) | App token / usage |
|--------------------------------------|-------------------|
| `--tg-theme-bg-color` | `--bg` (background) |
| `--tg-theme-secondary-bg-color` | `--surface` (cards, panels) |
| `--tg-theme-text-color` | `--text` (primary text) |
| `--tg-theme-hint-color`, `--tg-theme-subtitle-text-color` | `--muted` (secondary text) |
| `--tg-theme-link-color` | `--accent` (links, secondary actions) |
| `--tg-theme-header-bg-color` | `--header-bg` |
| `--tg-theme-section-bg-color` | `--card` (sections) |
| `--tg-theme-section-header-text-color` | `--section-header` |
| `--tg-theme-section-separator-color` | `--border` |
| `--tg-theme-button-color` | `--primary` |
| `--tg-theme-button-text-color` | `--primary-foreground` |
| `--tg-theme-destructive-text-color` | `--error` |
| `--tg-theme-accent-text-color` | `--accent-text` |
Tailwind/shadcn semantic tokens are wired to these: `--background``--bg`, `--foreground``--text`, `--primary`, `--secondary``--surface`, etc.
### 2.3 Domain colors
Duty-specific semantics (fixed per theme, not from ThemeParams):
- `--duty` — duty shift (e.g. green).
- `--unavailable` — unavailable (e.g. amber).
- `--vacation` — vacation (e.g. blue).
- `--today` — “today” highlight; tied to `--tg-theme-accent-text-color` / `--tg-theme-link-color`.
Use Tailwind classes such as `bg-duty`, `border-l-unavailable`, `text-today`, `bg-today`, etc.
### 2.4 Derived tokens (color-mix)
Prefer these instead of ad-hoc color mixes:
- `--surface-hover`, `--surface-hover-10` — hover states for surfaces.
- `--surface-today-tint`, `--surface-muted-tint` — subtle tints.
- `--today-hover`, `--today-border`, `--today-border-selected`, `--today-gradient-end` — today state.
- `--muted-fade`, `--shadow-card`, etc.
Defined in `globals.css`; use via `var(--surface-hover)` in classes (e.g. `hover:bg-[var(--surface-hover)]`).
### 2.5 Rule for new work
Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-muted`, `bg-surface`, `text-accent`, `border-l-duty`, `bg-today`, etc.). Do not hardcode hex or RGB in new components or screens.
---
## 3. Layout and safe areas
### 3.1 Width
- **Token:** `--max-width-app: 420px` (in `@theme` in `globals.css`).
- **Usage:** Page wrapper uses `max-w-[var(--max-width-app)]` (e.g. in `page.tsx`, `CalendarPage.tsx`, `admin/page.tsx`). Content is centred with `mx-auto`.
### 3.2 Height
- **Viewport:** Prefer `min-h-[var(--tg-viewport-stable-height,100vh)]` for the main content area so the Mini App fills the visible height correctly when expanded/collapsed. Fallback `100vh` when not in Telegram.
- **Body:** In `globals.css`, `body` already has `min-height: var(--tg-viewport-stable-height, 100vh)` and `background: var(--bg)`.
### 3.3 Safe area and content safe area
- **CSS custom properties** (in `globals.css`): `--app-safe-top`, `--app-safe-bottom`, `--app-safe-left`, `--app-safe-right` use Telegram viewport content-safe-area insets with `env(safe-area-inset-*)` fallbacks. Use these for sticky positioning and padding so layout works on notched and landscape devices.
- **Class `.content-safe`**: Applies padding on all four sides using the above tokens so content does not sit under Telegram header, bottom bar, or side chrome (Bot API 8.0+). Use `.content-safe` on the **root container of each page** and on full-screen fallback screens (not-found, error, access denied).
- **Sticky headers:** Use `top-[var(--app-safe-top)]` (not `top-0`) for sticky elements (e.g. calendar header, admin header) so they sit below the Telegram UI instead of overlapping it.
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
Official terminology (Telegram docs) uses `safeAreaInset` and `contentSafeAreaInset`
plus events `safeAreaChanged` and `contentSafeAreaChanged`. In our code, these values
are exposed through SDK CSS bindings and consumed via app aliases (`--app-safe-*`).
When updating safe-area code, preserve this mapping and avoid mixing raw `env(...)`
and Telegram content-safe insets within the same component.
### 3.4 Sheets and modals
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
`pb-[calc(24px+env(safe-area-inset-bottom,0px))]`
See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
---
## 4. Typography and spacing
### 4.1 Font
- **Family:** `system-ui, -apple-system, sans-serif` (set in `globals.css` and Tailwind theme).
### 4.2 Patterns from the calendar and duty list
| Element | Classes / tokens |
|--------|-------------------|
| Month title | `text-[1.1rem]` / `sm:text-[1.25rem]`, `font-semibold` |
| Year (above month) | `text-xs`, `text-muted` |
| Nav buttons (prev/next month) | `size-10`, `rounded-[10px]` |
| Calendar day cell | `text-[0.85rem]`, `rounded-lg`, `p-1` |
| Duty timeline card | `px-2.5 py-2`, `rounded-lg` |
### 4.3 Page and block spacing
- Page container: `px-3 pb-6` in addition to `.content-safe`.
- Between sections: `mb-3`, `mb-4` as appropriate.
- Grids: `gap-1` for tight layouts (e.g. calendar grid), larger gaps where needed.
---
## 5. Component patterns
### 5.1 Buttons
- **Primary:** Use the default Button variant: `bg-primary text-primary-foreground` (from `webapp-next/src/components/ui/button.tsx`).
- **Secondary icon buttons** (e.g. calendar nav):
`bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95`
with `size-10` and `rounded-[10px]`.
- Keep focus visible (e.g. `focus-visible:outline-accent` or ring); do not remove outline without a visible replacement.
### 5.2 Cards
- **Background:** `bg-surface` or `bg-card` (both resolve to theme tokens).
- **Borders:** `border`, `--border` (section separator color).
- **Emphasis:** `var(--shadow-card)` for highlighted cards (e.g. current duty).
- **Left stripe by type:** `border-l-[3px]` with:
- `border-l-duty`, `border-l-unavailable`, `border-l-vacation` for event types;
- `border-l-today` for “current duty” (see `.border-l-today` in `globals.css`).
### 5.3 Calendar grid
- **Structure:** 7 columns × 6 rows; use `role="grid"` on the container and `role="gridcell"` on each cell.
- **Layout:** `min-h-[var(--calendar-grid-min-height)]`, cells `aspect-square` with `min-h-8`, `rounded-lg`, `gap-1`.
- **Today:** `bg-today text-[var(--bg)]`; hover `hover:bg-[var(--today-hover)]`.
- **Other month:** `opacity-40`, `pointer-events-none`, `bg-[var(--surface-muted-tint)]`.
### 5.4 Timeline list (duties)
- **Dates:** Horizontal line and vertical tick from shared CSS in `globals.css`:
`.duty-timeline-date`, `.duty-timeline-date--today` (with `::before` / `::after`).
- **Cards:** Same card rules as above; `border-l-[3px]` + type class; optional flip card for contacts (see `DutyTimelineCard.tsx`).
---
## 6. Motion and performance
### 6.1 Timing
- **Tokens:** `--transition-fast: 0.15s`, `--transition-normal: 0.25s`, `--ease-out: cubic-bezier(0.32, 0.72, 0, 1)`.
- Use these for transitions and short animations so behaviour is consistent and predictable.
### 6.2 Reduced motion
- **Rule:** `@media (prefers-reduced-motion: reduce)` in `globals.css` shortens animation and transition durations globally. New animations should remain optional or short so they degrade gracefully when reduced.
### 6.3 Android low-performance devices
- **Detection:** `webapp-next/src/lib/telegram-android-perf.ts` reads Telegrams User-Agent and sets `data-perf="low"` on the document root when the device performance class is LOW.
- **CSS:** `[data-perf="low"] *` in `globals.css` minimizes animation/transition duration. Avoid adding heavy or long animations without considering this; prefer simple or no animation on low-end devices.
---
## 7. Accessibility
- **Focus:** Use `focus-visible:outline-accent` (or equivalent ring) on interactive elements; do not remove focus outline without a visible alternative.
- **Calendar:** Use `role="grid"` and `role="gridcell"`, `aria-label` on nav buttons (e.g. “Previous month”), and a composite `aria-label` on each day cell (date + event types). See `webapp-next/src/components/calendar/CalendarDay.tsx`.
- **Images and inputs:** Always provide labels (per Telegrams guidelines and WCAG).
---
## 8. Telegram integration
- **Ready gate:** `callMiniAppReadyOnce()` (in `lib/telegram-ready.ts`) is invoked by the layouts `ReadyGate` when `appContentReady` becomes true. Any route (/, /admin, not-found, in-app error) that sets `appContentReady` will trigger it so Telegram hides its loader; no route-specific logic is required.
- **Header and background:** On init (layout script and providers theme sync), call:
- `setBackgroundColor('bg_color')`
- `setHeaderColor('bg_color')`
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
- **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible.
### 8.1 Native control policy
- Use platform wrappers in `src/hooks/telegram/` rather than direct SDK calls in
feature components.
- **BackButton:** preferred for route-level back navigation in Telegram context.
- **SettingsButton:** use for route actions like opening `/admin` from calendar.
- **Main/Secondary button:** optional; use only if action must align with Telegram
bottom action affordance (do not duplicate with conflicting in-app primary CTA).
- **Haptics:** trigger only on meaningful user actions (submit, confirm, close).
### 8.2 Swipe and closing policy
- Keep vertical swipes enabled by default (`enableVerticalSwipes` behavior).
- Disable vertical swipes only on screens with explicit gesture conflict and document
the reason in code review.
- Enable closing confirmation only for stateful flows where accidental close can
lose user intent (e.g. reassignment flow in admin sheet).
### 8.3 Fullscreen/newer APIs policy
- Fullscreen APIs (`requestFullscreen`, `exitFullscreen`) are currently optional and
out of scope unless a feature explicitly requires immersive mode.
- If fullscreen is introduced, review safe area/content safe area and verify
`safeAreaChanged`, `contentSafeAreaChanged`, and `fullscreenChanged` handling.
---
## 9. Checklist for new screens and components
Use this for review when adding or changing UI:
- [ ] Use only design tokens from `globals.css` and Tailwind/shadcn aliases; no hardcoded colours.
- [ ] Page wrapper has `.content-safe`, `max-w-[var(--max-width-app)]`, and appropriate min-height (viewport-stable-height or `min-h-screen` with fallback).
- [ ] Buttons and cards follow the patterns above (variants, surfaces, border-l by type).
- [ ] Safe area is respected for bottom padding and for sheets/modals.
- [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles.
- [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android).
- [ ] User-facing strings and `aria-label`/`sr-only` text are localized via i18n (no hardcoded English in shared UI).
- [ ] Telegram controls are connected through platform hooks (`src/hooks/telegram/*`) instead of direct SDK calls.
- [ ] Vertical swipe and closing confirmation behavior follows the policy above.
## 10. Verification matrix (Mini App)
At minimum verify:
- Telegram light + dark themes.
- iOS and Android safe area/content-safe-area behavior (portrait + landscape).
- Android low-performance behavior (`data-perf="low"`).
- Deep link current duty (`startParam=duty`).
- Direct `/admin` open and reassignment flow.
- Access denied, not-found, and error boundary screens.
- Calendar swipe navigation with sticky header and native Telegram controls.

View File

@@ -0,0 +1,119 @@
# Webapp-next Refactor Baseline Audit
This note captures the baseline before the phased refactor. It defines current risks,
duplication hotspots, and expected behavior that must not regress.
## 1) Screens and boundaries
- Home route orchestration: `webapp-next/src/app/page.tsx`
- Chooses among `AccessDeniedScreen`, `CurrentDutyView`, `CalendarPage`.
- Controls app visibility via `appContentReady`.
- Admin route orchestration: `webapp-next/src/app/admin/page.tsx`
- Thin route, but still owns shell duplication and content-ready signaling.
- Calendar composition root: `webapp-next/src/components/CalendarPage.tsx`
- Combines sticky layout, swipe, month loading, auto-refresh, settings button.
- Current duty feature root: `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
- Combines data loading, error/access states, back button, and close action.
- Admin feature state root: `webapp-next/src/components/admin/useAdminPage.ts`
- Combines SDK button handling, admin access, users/duties loading, sheet state,
mutation and infinite scroll concerns.
## 2) Telegram integration touchpoints
- SDK/provider bootstrap:
- `webapp-next/src/components/providers/TelegramProvider.tsx`
- `webapp-next/src/components/ReadyGate.tsx`
- `webapp-next/src/lib/telegram-ready.ts`
- Direct control usage in feature code:
- `backButton` in `CurrentDutyView` and `useAdminPage`
- `settingsButton` in `CalendarPage`
- `closeMiniApp` in `CurrentDutyView`
- Haptics in feature-level handlers:
- `webapp-next/src/lib/telegram-haptic.ts`
Risk: platform behavior is spread across feature components instead of a narrow
platform boundary.
## 3) Layout and shell duplication
Repeated outer wrappers appear across route and state screens:
- `content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background`
- `mx-auto flex w-full max-w-[var(--max-width-app)] flex-col`
Known locations:
- `webapp-next/src/app/page.tsx`
- `webapp-next/src/app/admin/page.tsx`
- `webapp-next/src/components/CalendarPage.tsx`
- `webapp-next/src/components/states/FullScreenStateShell.tsx`
- `webapp-next/src/app/not-found.tsx`
- `webapp-next/src/app/global-error.tsx`
Risk: future safe-area or viewport fixes require multi-file edits.
## 4) Readiness and lifecycle coupling
`appContentReady` is set by multiple screens/routes:
- `page.tsx`
- `admin/page.tsx`
- `CalendarPage.tsx`
- `CurrentDutyView.tsx`
`ReadyGate` is route-agnostic, but signaling is currently ad hoc.
Risk: race conditions or deadlock-like "hidden app" scenarios when screen states
change in future refactors.
## 5) Async/data-loading duplication
Repeated manual patterns (abort, retries, state machine):
- `webapp-next/src/hooks/use-month-data.ts`
- `webapp-next/src/components/current-duty/CurrentDutyView.tsx`
- `webapp-next/src/components/admin/useAdminPage.ts`
Risk: inconsistent retry/access-denied behavior and difficult maintenance.
## 6) Store mixing concerns
`webapp-next/src/store/app-store.ts` currently mixes:
- session/platform concerns (`lang`, `appContentReady`, `isAdmin`)
- calendar/domain concerns (`currentMonth`, `pendingMonth`, duties/events)
- view concerns (`currentView`, `selectedDay`, `error`, `accessDenied`)
Risk: high coupling and larger blast radius for otherwise local changes.
## 7) i18n/a11y gaps to close
- Hardcoded grid label in `CalendarGrid`: `aria-label="Calendar"`.
- Hardcoded sr-only close text in shared `Sheet`: `"Close"`.
- Mixed language access strategy (`useTranslation()` vs `getLang()/translate()`),
valid for bootstrap/error boundary, but not explicitly codified in one place.
## 8) Telegram Mini Apps compliance checklist (baseline)
Already implemented well:
- Dynamic theme + runtime sync.
- Safe-area/content-safe-area usage via CSS vars and layout classes.
- `ready()` gate and Telegram loader handoff.
- Android low-performance class handling.
Needs explicit policy/consistency:
- Vertical swipes policy for gesture-heavy screens.
- Closing confirmation policy for stateful admin flows.
- Main/Secondary button usage policy for primary actions.
- Terminology alignment with current official docs:
`safeAreaInset`, `contentSafeAreaInset`, fullscreen events.
## 9) Expected behavior (non-regression)
- `/`:
- Shows access denied screen if not allowed.
- Opens current-duty view for `startParam=duty`.
- Otherwise opens calendar.
- `/admin`:
- Denies non-admin users.
- Loads users and duties for selected admin month.
- Allows reassignment with visible feedback.
- Error/fallback states:
- `not-found` and global error remain full-screen and theme-safe.
- Telegram UX:
- Back/settings controls remain functional in Telegram context.
- Ready handoff happens when first useful screen is visible.

View File

@@ -5,29 +5,43 @@ import re
from datetime import date, timedelta
import duty_teller.config as config
from starlette.middleware.base import BaseHTTPMiddleware
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, HTTPException, 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
from duty_teller.api.calendar_ics import get_calendar_events
from duty_teller.api.dependencies import (
_lang_from_accept_language,
fetch_duties_response,
get_authenticated_telegram_id_dep,
get_db_session,
get_validated_dates,
require_admin_telegram_id,
require_miniapp_username,
)
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
from duty_teller.cache import ics_calendar_cache
from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
from duty_teller.db.repository import (
get_duties,
get_duties_for_user,
get_duty_by_id,
get_user_by_calendar_token,
get_users_for_admin,
is_admin_for_telegram_user,
update_duty_user,
)
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
from duty_teller.db.models import User
from duty_teller.db.schemas import (
AdminDutyReassignBody,
CalendarEvent,
DutyInDb,
DutyWithUser,
UserForAdmin,
)
from duty_teller.i18n import t
log = logging.getLogger(__name__)
@@ -43,6 +57,16 @@ def _is_valid_calendar_token(token: str) -> bool:
app = FastAPI(title="Duty Teller API")
@app.exception_handler(Exception)
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Log unhandled exceptions and return 500 without exposing details to the client."""
log.exception("Unhandled exception: %s", exc)
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
@app.get("/health", summary="Health check")
def health() -> dict:
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
@@ -58,22 +82,103 @@ app.add_middleware(
)
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
class NoCacheStaticMiddleware:
"""
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
"""
async def dispatch(self, request, call_next):
response = await call_next(request)
path = request.url.path
if path.startswith("/app/") and (
path.endswith(".js") or path.endswith(".html")
):
response.headers["Cache-Control"] = "no-store"
return response
def __init__(self, app, **kwargs):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
is_app_path = path == "/app" or path.startswith("/app/")
async def send_wrapper(message):
if message["type"] == "http.response.start":
headers = list(message.get("headers", []))
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
if is_app_path:
cache_control = (b"cache-control", b"no-store")
if b"cache-control" in header_names:
headers[header_names[b"cache-control"]] = cache_control
else:
headers.append(cache_control)
vary_val = b"Accept-Language"
if b"vary" in header_names:
idx = header_names[b"vary"]
existing = headers[idx][1]
tokens = [p.strip() for p in existing.split(b",")]
if vary_val not in tokens:
headers[idx] = (b"vary", existing + b", " + vary_val)
else:
headers.append((b"vary", vary_val))
message = {
"type": "http.response.start",
"status": message["status"],
"headers": headers,
}
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(NoCacheStaticMiddleware)
# Allowed values for config.js to prevent script injection.
_VALID_LANGS = frozenset({"en", "ru"})
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
if value in allowed:
return value
return default
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
def _safe_tz_string(value: str) -> str:
"""Return value if it matches safe timezone pattern, else empty string."""
if value and _TZ_SAFE_RE.match(value.strip()):
return value.strip()
return ""
@app.get(
"/app/config.js",
summary="Mini App config (language, log level, timezone)",
description=(
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
"Loaded before main.js."
),
)
def app_config_js() -> Response:
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
body = (
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}\n'
'if (typeof window !== "undefined") window.dispatchEvent(new Event("dt-config-loaded"));'
)
return Response(
content=body,
media_type="application/javascript; charset=utf-8",
headers={"Cache-Control": "no-store"},
)
@app.get(
"/api/duties",
response_model=list[DutyWithUser],
@@ -132,10 +237,10 @@ def get_team_calendar_ical(
) -> Response:
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
if not _is_valid_calendar_token(token):
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
cache_key = ("team_ics",)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
@@ -173,10 +278,10 @@ def get_personal_calendar_ical(
No Telegram auth; access is by secret token in the URL.
"""
if not _is_valid_calendar_token(token):
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
user = get_user_by_calendar_token(session, token)
if user is None:
return Response(status_code=404, content="Not found")
return JSONResponse(status_code=404, content={"detail": "Not found"})
cache_key = ("personal_ics", user.id)
ics_bytes, found = ics_calendar_cache.get(cache_key)
if not found:
@@ -194,6 +299,96 @@ def get_personal_calendar_ical(
)
webapp_path = config.PROJECT_ROOT / "webapp"
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
@app.get(
"/api/admin/me",
summary="Check admin status",
description=(
"Returns is_admin for the authenticated Mini App user. "
"Requires valid initData (same as /api/duties)."
),
)
def admin_me(
_username: str = Depends(require_miniapp_username),
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
session: Session = Depends(get_db_session),
) -> dict:
"""Return { is_admin: true } or { is_admin: false } for the current user."""
is_admin = is_admin_for_telegram_user(session, telegram_id)
return {"is_admin": is_admin}
@app.get(
"/api/admin/users",
response_model=list[UserForAdmin],
summary="List users for admin dropdown",
description="Returns id, full_name, username for all users. Admin only.",
)
def admin_list_users(
_admin_telegram_id: int = Depends(require_admin_telegram_id),
session: Session = Depends(get_db_session),
) -> list[UserForAdmin]:
"""Return all users ordered by full_name for admin reassign dropdown."""
users = get_users_for_admin(session)
return [
UserForAdmin(
id=u.id,
full_name=u.full_name,
username=u.username,
role_id=u.role_id,
)
for u in users
]
@app.patch(
"/api/admin/duties/{duty_id}",
response_model=DutyInDb,
summary="Reassign duty to another user",
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
)
def admin_reassign_duty(
duty_id: int,
body: AdminDutyReassignBody,
request: Request,
_admin_telegram_id: int = Depends(require_admin_telegram_id),
session: Session = Depends(get_db_session),
) -> DutyInDb:
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
if duty_id <= 0 or body.user_id <= 0:
raise HTTPException(
status_code=400,
detail=t(lang, "api.bad_request"),
)
duty = get_duty_by_id(session, duty_id)
if duty is None:
raise HTTPException(
status_code=404,
detail=t(lang, "admin.duty_not_found"),
)
if session.get(User, body.user_id) is None:
raise HTTPException(
status_code=400,
detail=t(lang, "admin.user_not_found"),
)
updated = update_duty_user(session, duty_id, body.user_id, commit=True)
if updated is None:
raise HTTPException(
status_code=404,
detail=t(lang, "admin.duty_not_found"),
)
invalidate_duty_related_caches()
return DutyInDb(
id=updated.id,
user_id=updated.user_id,
start_at=updated.start_at,
end_at=updated.end_at,
)
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
@@ -13,46 +12,23 @@ from duty_teller.db.repository import (
get_duties,
get_user_by_telegram_id,
can_access_miniapp_for_telegram_user,
is_admin_for_telegram_user,
)
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 +43,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.
@@ -179,6 +160,103 @@ def get_authenticated_username(
return username or (user.full_name or "") or f"id:{telegram_user_id}"
def get_authenticated_telegram_id(
request: Request,
x_telegram_init_data: str | None,
session: Session,
) -> int:
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
Same validation as get_authenticated_username. Used to check is_admin.
Args:
request: FastAPI request (for Accept-Language in error messages).
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
session: DB session.
Returns:
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
Raises:
HTTPException: 403 if initData missing/invalid or user not in allowlist.
"""
if config.MINI_APP_SKIP_AUTH:
return 0
init_data = (x_telegram_init_data or "").strip()
if not init_data:
log.warning("no X-Telegram-Init-Data header")
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
init_data, config.BOT_TOKEN, max_age_seconds=max_age
)
if auth_reason != "ok":
log.warning("initData validation failed: %s", auth_reason)
raise HTTPException(
status_code=403, detail=_auth_error_detail(auth_reason, lang)
)
if telegram_user_id is None:
log.warning("initData valid but telegram_user_id missing")
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
user = get_user_by_telegram_id(session, telegram_user_id)
if not user:
log.warning(
"user not in DB (username=%s, telegram_id=%s)",
username,
telegram_user_id,
)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
failed_phone = config.normalize_phone(user.phone) if user.phone else None
log.warning(
"access denied (username=%s, telegram_id=%s, phone=%s)",
username,
telegram_user_id,
failed_phone or "",
)
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
return telegram_user_id
def get_authenticated_telegram_id_dep(
request: Request,
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
session: Session = Depends(get_db_session),
) -> int:
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
def require_admin_telegram_id(
request: Request,
x_telegram_init_data: Annotated[
str | None, Header(alias="X-Telegram-Init-Data")
] = None,
session: Session = Depends(get_db_session),
) -> int:
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
Raises:
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
"""
if config.MINI_APP_SKIP_AUTH:
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
telegram_user_id = get_authenticated_telegram_id(
request, x_telegram_init_data, session
)
if not is_admin_for_telegram_user(session, telegram_user_id):
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
return telegram_user_id
def fetch_duties_response(
session: Session, from_date: str, to_date: str
) -> list[DutyWithUser]:

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ from duty_teller.db.models import Base, User, Duty, Role
from duty_teller.db.schemas import (
UserCreate,
UserInDb,
UserForAdmin,
DutyCreate,
DutyInDb,
DutyWithUser,
@@ -16,11 +17,14 @@ from duty_teller.db.session import (
)
from duty_teller.db.repository import (
delete_duties_in_range,
get_duties,
get_duty_by_id,
get_or_create_user,
get_or_create_user_by_full_name,
get_duties,
get_users_for_admin,
insert_duty,
set_user_phone,
update_duty_user,
update_user_display_name,
)
@@ -31,6 +35,7 @@ __all__ = [
"Role",
"UserCreate",
"UserInDb",
"UserForAdmin",
"DutyCreate",
"DutyInDb",
"DutyWithUser",
@@ -39,11 +44,14 @@ __all__ = [
"get_session",
"session_scope",
"delete_duties_in_range",
"get_duties",
"get_duty_by_id",
"get_or_create_user",
"get_or_create_user_by_full_name",
"get_duties",
"get_users_for_admin",
"insert_duty",
"set_user_phone",
"update_duty_user",
"update_user_display_name",
"init_db",
]

View File

@@ -7,6 +7,7 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session
import duty_teller.config as config
from duty_teller.db.schemas import DUTY_EVENT_TYPES
from duty_teller.db.models import (
User,
Duty,
@@ -201,14 +202,19 @@ def get_or_create_user(
return user
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
def get_or_create_user_by_full_name(
session: Session, full_name: str, *, commit: bool = True
) -> User:
"""Find user by exact full_name or create one (for duty-schedule import).
New users have telegram_user_id=None and name_manually_edited=True.
When commit=False, caller is responsible for committing (e.g. single commit
per import in run_import).
Args:
session: DB session.
full_name: Exact full name to match or set.
commit: If True, commit immediately. If False, caller commits.
Returns:
User instance (existing or newly created).
@@ -225,8 +231,11 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
name_manually_edited=True,
)
session.add(user)
if commit:
session.commit()
session.refresh(user)
else:
session.flush() # Assign id so caller can use user.id before commit
return user
@@ -313,6 +322,61 @@ def delete_duties_in_range(
return count
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
"""Return duty by primary key.
Args:
session: DB session.
duty_id: Duty id (duties.id).
Returns:
Duty or None if not found.
"""
return session.get(Duty, duty_id)
def update_duty_user(
session: Session,
duty_id: int,
new_user_id: int,
*,
commit: bool = True,
) -> Duty | None:
"""Update the assigned user of a duty.
Args:
session: DB session.
duty_id: Duty id (duties.id).
new_user_id: New user id (users.id).
commit: If True, commit immediately. If False, caller commits.
Returns:
Updated Duty or None if duty not found.
"""
duty = session.get(Duty, duty_id)
if duty is None:
return None
duty.user_id = new_user_id
if commit:
session.commit()
session.refresh(duty)
else:
session.flush()
return duty
def get_users_for_admin(session: Session) -> list[User]:
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
Args:
session: DB session.
Returns:
List of User instances ordered by full_name.
"""
return session.query(User).order_by(User.full_name).all()
def get_duties(
session: Session,
from_date: str,
@@ -447,11 +511,13 @@ def insert_duty(
user_id: User id.
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
end_at: End time UTC, ISO 8601 with Z.
event_type: One of "duty", "unavailable", "vacation". Default "duty".
event_type: One of "duty", "unavailable", "vacation". Invalid values are stored as "duty".
Returns:
Created Duty instance.
"""
if event_type not in DUTY_EVENT_TYPES:
event_type = "duty"
duty = Duty(
user_id=user_id,
start_at=start_at,

View File

@@ -69,6 +69,21 @@ class DutyWithUser(DutyInDb):
model_config = ConfigDict(from_attributes=True)
class UserForAdmin(BaseModel):
"""User summary for admin dropdown: id, full_name, username, role_id."""
id: int
full_name: str
username: str | None = None
role_id: int | None = None
class AdminDutyReassignBody(BaseModel):
"""Request body for PATCH /api/admin/duties/:id — reassign duty to another user."""
user_id: int
class CalendarEvent(BaseModel):
"""External calendar event (e.g. holiday) for a single day."""

View File

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

View File

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

View File

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

View File

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

View File

@@ -72,7 +72,9 @@ MESSAGES: dict[str, dict[str, str]] = {
"import.send_json": "Send the duty-schedule file (JSON).",
"import.need_json": "File must have .json extension.",
"import.parse_error": "File parse error: {error}",
"import.parse_error_generic": "The file could not be parsed. Check the format and try again.",
"import.import_error": "Import error: {error}",
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
"import.done": (
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
"({total} events total)."
@@ -86,8 +88,10 @@ MESSAGES: dict[str, dict[str, str]] = {
),
"api.auth_invalid": "Invalid auth data",
"api.access_denied": "Access denied",
"api.bad_request": "Bad request",
"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",
@@ -95,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = {
"current_duty.shift": "Shift",
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar",
"admin.duty_not_found": "Duty not found",
"admin.user_not_found": "User not found",
"admin.reassign_success": "Duty reassigned successfully",
},
"ru": {
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
@@ -160,7 +167,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} отпусков",
@@ -169,8 +178,10 @@ MESSAGES: dict[str, dict[str, str]] = {
"из которого открыт календарь (тот же бот, что в меню).",
"api.auth_invalid": "Неверные данные авторизации",
"api.access_denied": "Доступ запрещён",
"api.bad_request": "Неверный запрос",
"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": "Текущее дежурство",
@@ -178,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = {
"current_duty.shift": "Смена",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю",
"admin.duty_not_found": "Дежурство не найдено",
"admin.user_not_found": "Пользователь не найден",
"admin.reassign_success": "Дежурство успешно переназначено",
},
}

View File

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

View File

@@ -3,7 +3,9 @@
import asyncio
import json
import logging
import sys
import threading
import time
import urllib.request
from telegram.ext import ApplicationBuilder
@@ -13,6 +15,15 @@ from duty_teller.config import require_bot_token
from duty_teller.handlers import group_duty_pin, register_handlers
from duty_teller.utils.http_client import safe_urlopen
# Seconds to wait for HTTP server to bind before health check.
_HTTP_STARTUP_WAIT_SEC = 3
async def _post_init(application) -> None:
"""Run startup tasks: restore group pin jobs, then resolve bot username."""
await group_duty_pin.restore_group_pin_jobs(application)
await _resolve_bot_username(application)
async def _resolve_bot_username(application) -> None:
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
@@ -24,7 +35,7 @@ async def _resolve_bot_username(application) -> None:
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.INFO,
level=config.LOG_LEVEL,
)
logger = logging.getLogger(__name__)
@@ -69,32 +80,59 @@ 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()
# Optional: set bot menu button to open the Miniapp. Uncomment to enable:
# _set_default_menu_button_webapp()
app = (
ApplicationBuilder()
.token(config.BOT_TOKEN)
.post_init(group_duty_pin.restore_group_pin_jobs)
.post_init(_resolve_bot_username)
.build()
)
app = ApplicationBuilder().token(config.BOT_TOKEN).post_init(_post_init).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

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

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "duty-teller"
version = "0.1.0"
version = "2.1.3"
description = "Telegram bot for team duty shift calendar and group reminder"
readme = "README.md"
requires-python = ">=3.11"

396
tests/test_admin_api.py Normal file
View File

@@ -0,0 +1,396 @@
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
from unittest.mock import ANY, MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
from duty_teller.api.app import app
from duty_teller.api.dependencies import get_db_session
@pytest.fixture
def client():
return TestClient(app)
def _override_get_db_session(mock_session: MagicMock):
"""Dependency override that returns mock_session (no real DB). Used as get_db_session override."""
def _override() -> Session:
return mock_session
return _override
# --- GET /api/admin/me ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_me_skip_auth_returns_is_admin_false(client):
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
# Override get_db_session so the endpoint does not open the real DB (CI has no data/ dir).
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.first.return_value = None
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
try:
r = client.get("/api/admin/me")
assert r.status_code == 200
assert r.json() == {"is_admin": False}
finally:
app.dependency_overrides.pop(get_db_session, None)
@patch("duty_teller.api.app.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_me_returns_is_admin_true_when_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""When user is admin, GET /api/admin/me returns is_admin: true."""
from types import SimpleNamespace
mock_validate.return_value = (100, "user", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
r = client.get(
"/api/admin/me",
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
},
)
assert r.status_code == 200
assert r.json() == {"is_admin": True}
@patch("duty_teller.api.app.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_me_returns_is_admin_false_when_not_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
from types import SimpleNamespace
mock_validate.return_value = (200, "user", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
mock_can_access.return_value = True
mock_is_admin.return_value = False
r = client.get(
"/api/admin/me",
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"
},
)
assert r.status_code == 200
assert r.json() == {"is_admin": False}
# --- GET /api/admin/users ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_403_without_init_data(client):
"""GET /api/admin/users without initData returns 403."""
r = client.get("/api/admin/users")
assert r.status_code == 403
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_403_when_not_admin(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
):
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
from types import SimpleNamespace
mock_validate.return_value = (100, "u", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
mock_can_access.return_value = True
mock_is_admin.return_value = False # not admin
r = client.get(
"/api/admin/users",
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"
},
)
assert r.status_code == 403
detail = r.json()["detail"]
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
@patch("duty_teller.api.app.get_users_for_admin")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_users_200_returns_list(
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
):
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_users.return_value = [
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
]
r = client.get(
"/api/admin/users",
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 200
data = r.json()
assert len(data) == 2
assert data[0]["id"] == 1
assert data[0]["full_name"] == "Alice"
assert data[0]["username"] == "alice"
assert data[0]["role_id"] == 1
assert data[1]["id"] == 2
assert data[1]["full_name"] == "Bob"
assert data[1]["username"] is None
assert data[1]["role_id"] == 2
# --- PATCH /api/admin/duties/:id ---
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_403_without_auth(client):
"""PATCH /api/admin/duties/1 without auth returns 403."""
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
)
assert r.status_code == 403
@patch("duty_teller.api.app.require_admin_telegram_id")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
"""PATCH /api/admin/duties/1 when not admin returns 403."""
from fastapi import HTTPException
from duty_teller.i18n import t
mock_require_admin.side_effect = HTTPException(
status_code=403, detail=t("en", "import.admin_only")
)
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
headers={"X-Telegram-Init-Data": "x"},
)
assert r.status_code == 403
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_404_when_duty_missing(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = None
r = client.patch(
"/api/admin/duties/999",
json={"user_id": 2},
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
)
assert r.status_code == 404
mock_update.assert_not_called()
mock_invalidate.assert_not_called()
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_400_when_user_not_found(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = SimpleNamespace(
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
)
mock_session = MagicMock()
mock_session.get.return_value = None # User not found
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
try:
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 999},
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
},
)
finally:
app.dependency_overrides.pop(get_db_session, None)
assert r.status_code == 400
mock_update.assert_not_called()
mock_invalidate.assert_not_called()
@patch("duty_teller.api.app.invalidate_duty_related_caches")
@patch("duty_teller.api.app.update_duty_user")
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_200_updates_and_invalidates(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
mock_update_duty_user,
mock_invalidate,
client,
):
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
duty = SimpleNamespace(
id=1,
user_id=10,
start_at="2026-01-15T09:00:00Z",
end_at="2026-01-15T18:00:00Z",
)
updated_duty = SimpleNamespace(
id=1,
user_id=2,
start_at="2026-01-15T09:00:00Z",
end_at="2026-01-15T18:00:00Z",
)
mock_get_duty.return_value = duty
mock_update_duty_user.return_value = updated_duty
mock_session = MagicMock()
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
app.dependency_overrides[get_db_session] = _override_get_db_session(mock_session)
try:
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"
},
)
finally:
app.dependency_overrides.pop(get_db_session, None)
assert r.status_code == 200
data = r.json()
assert data["id"] == 1
assert data["user_id"] == 2
assert data["start_at"] == "2026-01-15T09:00:00Z"
assert data["end_at"] == "2026-01-15T18:00:00Z"
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
mock_invalidate.assert_called_once()
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_users_403_when_skip_auth(client):
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
r = client.get("/api/admin/users")
assert r.status_code == 403
detail = r.json()["detail"]
assert "admin" in detail.lower() or "администратор" in detail
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
def test_admin_reassign_403_when_skip_auth(client):
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
r = client.patch(
"/api/admin/duties/1",
json={"user_id": 2},
)
assert r.status_code == 403
@patch("duty_teller.api.app.get_duty_by_id")
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
def test_admin_reassign_404_uses_accept_language_for_detail(
mock_validate,
mock_get_user,
mock_can_access,
mock_is_admin,
mock_get_duty,
client,
):
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
from types import SimpleNamespace
mock_validate.return_value = (1, "admin", "ok", "en")
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
mock_can_access.return_value = True
mock_is_admin.return_value = True
mock_get_duty.return_value = None
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
mock_lang.return_value = "ru"
r = client.patch(
"/api/admin/duties/999",
json={"user_id": 2},
headers={
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
"Accept-Language": "ru",
},
)
assert r.status_code == 404
assert r.json()["detail"] == "Дежурство не найдено"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
delete_duties_in_range,
get_duties,
get_duties_for_user,
get_duty_by_id,
get_or_create_user,
get_or_create_user_by_full_name,
get_users_for_admin,
insert_duty,
update_duty_user,
update_user_display_name,
)
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
assert u2.username == "new_username"
def test_get_duty_by_id_returns_duty(session, user_a):
"""get_duty_by_id returns the duty when it exists."""
duty = insert_duty(
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
)
found = get_duty_by_id(session, duty.id)
assert found is not None
assert found.id == duty.id
assert found.user_id == user_a.id
assert found.start_at == "2026-02-01T09:00:00Z"
def test_get_duty_by_id_returns_none_when_missing(session):
"""get_duty_by_id returns None for non-existent id."""
assert get_duty_by_id(session, 99999) is None
def test_update_duty_user_changes_user(session, user_a):
"""update_duty_user updates user_id and returns the duty."""
user_b = get_or_create_user_by_full_name(session, "User B")
duty = insert_duty(
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
)
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
assert updated is not None
assert updated.id == duty.id
assert updated.user_id == user_b.id
session.refresh(duty)
assert duty.user_id == user_b.id
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
"""update_duty_user returns None when duty does not exist."""
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
"""get_users_for_admin returns all users ordered by full_name."""
get_or_create_user_by_full_name(session, "Alice")
get_or_create_user_by_full_name(session, "Борис")
users = get_users_for_admin(session)
assert len(users) >= 3
full_names = [u.full_name for u in users]
assert full_names == sorted(full_names)
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
get_or_create_user(

View File

@@ -24,10 +24,19 @@ 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.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.db.session.session_scope", mock_scope):
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):

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

@@ -0,0 +1,244 @@
/**
* Component tests for admin page: render, access denied, duty list, reassign sheet.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import AdminPage from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const mockUseTelegramAuth = vi.mocked(
await import("@/hooks/use-telegram-auth").then((m) => m.useTelegramAuth)
);
const sampleUsers = [
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
];
const sampleDuties: Array<{
id: number;
user_id: number;
start_at: string;
end_at: string;
full_name: string;
event_type: string;
phone: string | null;
username: string | null;
}> = [
{
id: 10,
user_id: 1,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
full_name: "Alice",
event_type: "duty",
phone: null,
username: "alice",
},
];
function mockFetchForAdmin(
users: Array<{ id: number; full_name: string; username: string | null; role_id?: number }> = sampleUsers,
duties = sampleDuties,
options?: { adminMe?: { is_admin: boolean } }
) {
const adminMe = options?.adminMe ?? { is_admin: true };
vi.stubGlobal(
"fetch",
vi.fn((url: string, _init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(adminMe),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(users),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(duties),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
}
describe("AdminPage", () => {
beforeEach(() => {
resetAppStore();
useAppStore.getState().setCurrentMonth(new Date(2025, 0, 1));
mockUseTelegramAuth.mockReturnValue({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
});
});
it("shows admin title (month/year) when allowed and data loaded", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
});
});
it("shows access denied when fetchAdminMe returns is_admin false", async () => {
mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } });
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows access denied message when GET /api/admin/users returns 403", async () => {
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
}
return Promise.reject(new Error(`Unexpected: ${url}`));
})
);
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Admin only|Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows duty row and opens reassign sheet on click", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
const dutyButton = screen.getByRole("button", { name: /Alice/ });
fireEvent.click(dutyButton);
await waitFor(() => {
expect(
screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })
).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
});
it("shows no users message in sheet when usersForSelect is empty", async () => {
mockFetchForAdmin([], sampleDuties);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(
screen.getByText(/No users available for assignment|Нет пользователей для назначения/i)
).toBeInTheDocument();
});
});
it("shows success message after successful reassign", async () => {
mockFetchForAdmin();
vi.stubGlobal(
"fetch",
vi.fn((url: string, init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleUsers),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleDuties),
} as Response);
}
if (url.includes("/api/admin/duties/") && init?.method === "PATCH") {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 10,
user_id: 2,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
}),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("radio", { name: /Bob/ }));
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
await waitFor(() => {
expect(
screen.getByText(/Duty reassigned|Дежурство переназначено/i)
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,131 @@
/**
* Admin page: list duties for the month and reassign duty to another user.
* Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin).
* Logic and heavy UI live in components/admin (useAdminPage, AdminDutyList, ReassignSheet).
*/
"use client";
import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
import { useTranslation } from "@/i18n/use-translation";
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState";
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
import { useScreenReady } from "@/hooks/use-screen-ready";
export default function AdminPage() {
const { t, monthName } = useTranslation();
const admin = useAdminPage();
useScreenReady(true);
if (!admin.isAllowed) {
return (
<MiniAppScreen>
<MiniAppScreenContent>
<AccessDeniedScreen primaryAction="reload" />
</MiniAppScreenContent>
</MiniAppScreen>
);
}
if (admin.adminCheckComplete === null) {
return (
<MiniAppScreen>
<MiniAppScreenContent>
<div className="py-4 flex flex-col items-center gap-2">
<LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div>
</MiniAppScreenContent>
</MiniAppScreen>
);
}
if (admin.adminAccessDenied) {
return (
<MiniAppScreen>
<MiniAppScreenContent>
<div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground">
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
</p>
</div>
</MiniAppScreenContent>
</MiniAppScreen>
);
}
const month = admin.adminMonth.getMonth();
const year = admin.adminMonth.getFullYear();
return (
<MiniAppScreen>
<MiniAppScreenContent>
<MiniAppStickyHeader className="flex flex-col items-center border-b border-border py-3">
<MonthNavHeader
month={admin.adminMonth}
disabled={admin.loading}
onPrevMonth={admin.onPrevMonth}
onNextMonth={admin.onNextMonth}
titleAriaLabel={`${t("admin.title")}, ${monthName(month)} ${year}`}
className="w-full px-1"
/>
</MiniAppStickyHeader>
{admin.successMessage && (
<p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite">
{admin.successMessage}
</p>
)}
{admin.loading && (
<div className="py-4 flex flex-col items-center gap-2">
<LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div>
)}
{admin.error && !admin.loading && (
<ErrorState
message={admin.error}
onRetry={() => window.location.reload()}
className="my-3"
/>
)}
{!admin.loading && !admin.error && (
<div className="mt-3 flex flex-col gap-2">
{admin.visibleGroups.length > 0 && (
<p className="text-sm text-muted-foreground">
{t("admin.reassign_duty")}: {t("admin.select_user")}
</p>
)}
<AdminDutyList
groups={admin.visibleGroups}
hasMore={admin.hasMore}
sentinelRef={admin.sentinelRef}
onSelectDuty={admin.openReassign}
t={t}
/>
</div>
)}
<ReassignSheet
open={!admin.sheetExiting && admin.selectedDuty !== null}
selectedDuty={admin.selectedDuty}
selectedUserId={admin.selectedUserId}
setSelectedUserId={admin.setSelectedUserId}
users={admin.usersForSelect}
saving={admin.saving}
reassignErrorKey={admin.reassignErrorKey}
onReassign={admin.handleReassign}
onRequestClose={admin.requestCloseSheet}
onCloseAnimationEnd={admin.closeReassign}
t={t}
/>
</MiniAppScreenContent>
</MiniAppScreen>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,55 @@
/**
* 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 { useEffect } from "react";
import "./globals.css";
import { getLang, translate } from "@/i18n/messages";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
import { Button } from "@/components/ui/button";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const lang = getLang();
useEffect(() => {
callMiniAppReadyOnce();
}, []);
return (
<html
lang={lang === "ru" ? "ru" : "en"}
data-theme="dark"
suppressHydrationWarning
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
</head>
<body className="antialiased">
<MiniAppScreen>
<MiniAppScreenContent className="items-center justify-center gap-4 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()}>
{translate(lang, "error_boundary.reload")}
</Button>
</MiniAppScreenContent>
</MiniAppScreen>
</body>
</html>
);
}

View File

@@ -0,0 +1,338 @@
@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-holiday-outline: color-mix(in srgb, var(--bg) 28%, var(--today));
--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;
/** Safe-area insets for sticky headers and full-screen (Telegram viewport + env fallbacks). */
--app-safe-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
--app-safe-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
--app-safe-left: var(--tg-viewport-content-safe-area-inset-left, env(safe-area-inset-left, 0));
--app-safe-right: var(--tg-viewport-content-safe-area-inset-right, env(safe-area-inset-right, 0));
/** 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: var(--tg-viewport-content-safe-area-inset-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;
}
}
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
[data-perf="low"] *,
[data-perf="low"] *::before,
[data-perf="low"] *::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: var(--app-safe-top);
}
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
Horizontal padding has a minimum of 0.75rem (12px) when safe insets are zero. */
.content-safe {
padding-top: var(--app-safe-top);
padding-bottom: var(--app-safe-bottom);
padding-left: max(var(--app-safe-left), 0.75rem);
padding-right: max(var(--app-safe-right), 0.75rem);
}
/* 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: var(--tg-viewport-stable-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,47 @@
import type { Metadata, Viewport } from "next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TelegramProvider } from "@/components/providers/TelegramProvider";
import { AppShell } from "@/components/AppShell";
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
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>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
<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>
<AppShell>{children}</AppShell>
</TooltipProvider>
</AppErrorBoundary>
</TelegramProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,30 @@
/**
* 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";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
import { useScreenReady } from "@/hooks/use-screen-ready";
export default function NotFound() {
const { t } = useTranslation();
useScreenReady(true);
return (
<FullScreenStateShell
title={t("not_found.title")}
description={t("not_found.description")}
primaryAction={
<Button asChild>
<Link href="/">{t("not_found.open_calendar")}</Link>
</Button>
}
role="status"
/>
);
}

View File

@@ -0,0 +1,65 @@
/**
* 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, act } from "@testing-library/react";
import Page from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
}));
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: vi.fn(),
}));
vi.mock("@/hooks/use-month-data", () => ({
useMonthData: () => ({
retry: vi.fn(),
}),
}));
describe("Page", () => {
beforeEach(() => {
resetAppStore();
vi.mocked(useTelegramAuth).mockReturnValue({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
});
});
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("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
const { RETRY_DELAY_MS } = await import("@/lib/constants");
vi.mocked(useTelegramAuth).mockReturnValue({
initDataRaw: undefined,
startParam: undefined,
isLocalhost: false,
});
vi.useFakeTimers();
render(<Page />);
await act(async () => {
vi.advanceTimersByTime(RETRY_DELAY_MS);
});
vi.useRealTimers();
expect(
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 2000 })
).toBeInTheDocument();
expect(
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Reload|Обновить/i })).toBeInTheDocument();
expect(screen.queryByRole("grid", { name: "Calendar" })).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,83 @@
/**
* 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, type AppState } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init";
import { fetchAdminMe } from "@/lib/api";
import { getLang } from "@/i18n/messages";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
import { useScreenReady } from "@/hooks/use-screen-ready";
export default function Home() {
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
useAppInit({ isAllowed, startParam });
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
useEffect(() => {
if (!isAllowed || !initDataRaw) {
setIsAdmin(false);
return;
}
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
}, [isAllowed, initDataRaw, setIsAdmin]);
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore(
useShallow((s: AppState) => ({
accessDenied: s.accessDenied,
currentView: s.currentView,
setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay,
appContentReady: s.appContentReady,
}))
);
useScreenReady(accessDenied || currentView === "currentDuty");
const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar");
setSelectedDay(null);
}, [setCurrentView, setSelectedDay]);
const content = accessDenied ? (
<MiniAppScreen>
<MiniAppScreenContent>
<AccessDeniedScreen primaryAction="reload" />
</MiniAppScreenContent>
</MiniAppScreen>
) : currentView === "currentDuty" ? (
<MiniAppScreen>
<MiniAppScreenContent>
<CurrentDutyView
onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"}
/>
</MiniAppScreenContent>
</MiniAppScreen>
) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
);
return (
<div
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
style={{
visibility: appContentReady ? "visible" : "hidden",
}}
>
{content}
</div>
);
}

View File

@@ -0,0 +1,71 @@
/**
* 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, translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
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 description = translate(lang, "error_boundary.description");
const reloadLabel = translate(lang, "error_boundary.reload");
return (
<FullScreenStateShell
title={message}
description={description}
primaryAction={
<Button type="button" variant="default" onClick={this.handleReload}>
{reloadLabel}
</Button>
}
/>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,17 @@
/**
* App shell: wraps children with ReadyGate so any route can trigger miniAppReady().
* Rendered inside TelegramProvider so theme and SDK are available.
*/
"use client";
import { ReadyGate } from "@/components/ReadyGate";
export function AppShell({ children }: { children: React.ReactNode }) {
return (
<>
<ReadyGate />
{children}
</>
);
}

View File

@@ -0,0 +1,181 @@
/**
* 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 { useRouter } from "next/navigation";
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 { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
import { useTelegramSettingsButton, useTelegramVerticalSwipePolicy } from "@/hooks/telegram";
import { DISABLE_VERTICAL_SWIPES_BY_DEFAULT } from "@/lib/telegram-interaction-policy";
import { useScreenReady } from "@/hooks/use-screen-ready";
/** 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,
duties,
calendarEvents,
selectedDay,
isAdmin,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
pendingMonth: s.pendingMonth,
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
isAdmin: s.isAdmin,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
}))
);
const router = useRouter();
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);
useTelegramVerticalSwipePolicy(DISABLE_VERTICAL_SWIPES_BY_DEFAULT);
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]);
useScreenReady(!loading || accessDenied);
useTelegramSettingsButton({
enabled: isAdmin,
onClick: () => router.push("/admin"),
});
return (
<MiniAppScreen>
<MiniAppScreenContent>
<MiniAppStickyHeader
ref={calendarStickyRef}
className="min-h-[var(--calendar-block-min-height)] pb-2 touch-pan-y"
>
<CalendarHeader
month={currentMonth}
disabled={navDisabled}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
<CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
</MiniAppStickyHeader>
{error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</MiniAppScreenContent>
</MiniAppScreen>
);
}

View File

@@ -0,0 +1,23 @@
/**
* Route-agnostic gate: when appContentReady becomes true, calls miniAppReady() once
* so Telegram hides its native loader. Used in layout so any route (/, /admin, not-found,
* error) can trigger ready when its first screen is shown.
*/
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
export function ReadyGate() {
const appContentReady = useAppStore((s) => s.appContentReady);
useEffect(() => {
if (appContentReady) {
callMiniAppReadyOnce();
}
}, [appContentReady]);
return null;
}

View File

@@ -0,0 +1,120 @@
/**
* Admin duty list: visible duties with infinite-scroll sentinel. Presentational only.
*/
"use client";
import { useRouter } from "next/navigation";
import type { DutyWithUser } from "@/types";
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface AdminDutyGroup {
dateKey: string;
duties: DutyWithUser[];
}
/** Empty state when there are no duties: message and "Back to calendar" CTA. */
function AdminEmptyState({
t,
}: {
t: (key: string, params?: Record<string, string>) => string;
}) {
const router = useRouter();
return (
<div className="flex flex-col items-center gap-4 py-8 text-center">
<h2 className="text-[1.1rem] font-semibold leading-tight m-0">
{t("admin.no_duties")}
</h2>
<p className="text-sm text-muted-foreground m-0 max-w-[280px]">
{t("duty.none_this_month_hint")}
</p>
<Button
type="button"
variant="secondary"
onClick={() => router.push("/")}
className="bg-surface text-accent hover:bg-[var(--surface-hover)]"
>
{t("admin.back_to_calendar")}
</Button>
</div>
);
}
export interface AdminDutyListProps {
/** Duty groups by date (already sliced to visibleCount by parent). */
groups: AdminDutyGroup[];
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
hasMore: boolean;
/** Ref for the sentinel element (infinite scroll). */
sentinelRef: React.RefObject<HTMLDivElement | null>;
/** Called when user selects a duty to reassign. */
onSelectDuty: (duty: DutyWithUser) => void;
/** Translation function. */
t: (key: string, params?: Record<string, string>) => string;
}
export function AdminDutyList({
groups,
hasMore,
sentinelRef,
onSelectDuty,
t,
}: AdminDutyListProps) {
if (groups.length === 0) {
return <AdminEmptyState t={t} />;
}
const todayKey = localDateString(new Date());
return (
<div className="flex flex-col gap-3" role="list" aria-label={t("admin.list_aria")}>
{groups.map(({ dateKey, duties }) => {
const isToday = dateKey === todayKey;
const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey);
return (
<section
key={dateKey}
className="flex flex-col gap-1.5"
aria-label={t("admin.section_aria", { date: dateLabel })}
>
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
{duties.map((duty) => {
const dateStr = localDateString(new Date(duty.start_at));
const timeStr = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
return (
<li key={duty.id}>
<button
type="button"
onClick={() => onSelectDuty(duty)}
aria-label={t("admin.reassign_aria", {
date: dateStr,
time: timeStr,
name: duty.full_name,
})}
className={cn(
"w-full min-h-0 rounded-lg border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5",
isToday ? "border-l-today bg-[var(--surface-today-tint)]" : "border-l-duty"
)}
>
<span>
<span className="font-medium">{dateStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">{timeStr}</span>
</span>
<span className="font-semibold">{duty.full_name}</span>
</button>
</li>
);
})}
</ul>
</section>
);
})}
{hasMore && (
<div ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
)}
</div>
);
}

View File

@@ -0,0 +1,197 @@
/**
* Bottom sheet for reassigning a duty to another user. Uses design tokens and safe area.
*/
"use client";
import type { DutyWithUser } from "@/types";
import type { UserForAdmin } from "@/lib/api";
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
export interface ReassignSheetProps {
/** Whether the sheet is open (and not in exiting state). */
open: boolean;
/** Selected duty to reassign; when null, content may still render with previous duty during close. */
selectedDuty: DutyWithUser | null;
/** Current selected user id for the select. */
selectedUserId: number | "";
/** Called when user changes selection. */
setSelectedUserId: (value: number | "") => void;
/** Users to show in the dropdown (role_id 1 or 2). */
users: UserForAdmin[];
/** Reassign request in progress. */
saving: boolean;
/** i18n key for error message from last reassign attempt; null when no error. */
reassignErrorKey: string | null;
/** Called when user confirms reassign. */
onReassign: () => void;
/** Called when user requests close (start close animation). */
onRequestClose: () => void;
/** Called when close animation ends (clear state). */
onCloseAnimationEnd: () => void;
/** Translation function. */
t: (key: string, params?: Record<string, string>) => string;
}
export function ReassignSheet({
open,
selectedDuty,
selectedUserId,
setSelectedUserId,
users,
saving,
reassignErrorKey,
onReassign,
onRequestClose,
onCloseAnimationEnd,
t,
}: ReassignSheetProps) {
const todayKey = localDateString(new Date());
const dateKey = selectedDuty
? localDateString(new Date(selectedDuty.start_at))
: "";
const sheetTitle = selectedDuty
? dateKey === todayKey
? t("duty.today") + ", " + dateKeyToDDMM(dateKey)
: dateKeyToDDMM(dateKey)
: t("admin.reassign_duty");
return (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
<SheetContent
side="bottom"
className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3"
overlayClassName="backdrop-blur-md"
showCloseButton={false}
closeLabel={t("day_detail.close")}
onCloseAnimationEnd={onCloseAnimationEnd}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="relative min-h-0 flex-1 overflow-y-auto px-4">
<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={onRequestClose}
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>
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
<SheetHeader className="p-0">
<SheetTitle>{sheetTitle}</SheetTitle>
<SheetDescription className="sr-only">
{t("admin.select_user")}
</SheetDescription>
</SheetHeader>
{selectedDuty && (
<div className="flex flex-col gap-4 pt-2 pb-4">
<p className="text-sm text-muted-foreground">
{formatHHMM(selectedDuty.start_at)} {formatHHMM(selectedDuty.end_at)}
</p>
<p className="text-xs text-muted-foreground">
{t("admin.current_assignee")}: {selectedDuty.full_name}
</p>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("admin.no_users_for_assign")}
</p>
) : (
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
{t("admin.select_user")}
</p>
<div
className="flex flex-col gap-1 max-h-[40vh] overflow-y-auto rounded-lg border border-border py-1"
role="radiogroup"
aria-label={t("admin.select_user")}
>
{users.map((u) => {
const isCurrent = u.id === selectedDuty.user_id;
const isSelected = selectedUserId === u.id;
return (
<button
key={u.id}
type="button"
role="radio"
aria-checked={isSelected}
disabled={saving}
onClick={() => setSelectedUserId(u.id)}
className={cn(
"flex flex-col items-start gap-0.5 rounded-md px-3 py-2.5 text-left text-sm transition-colors",
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent focus-visible:ring-2 focus-visible:ring-accent",
isCurrent && "bg-[var(--surface-today-tint)]",
isSelected && "ring-2 ring-accent ring-inset"
)}
>
<span className="font-medium">
{u.full_name}
{isCurrent && (
<span className="ml-2 text-xs text-muted-foreground font-normal">
({t("admin.current_assignee")})
</span>
)}
</span>
{u.username && (
<span className="text-xs text-muted-foreground">@{u.username}</span>
)}
</button>
);
})}
</div>
</div>
)}
{reassignErrorKey && (
<p id="admin-reassign-error" className="text-sm text-destructive" role="alert">
{t(reassignErrorKey)}
</p>
)}
</div>
)}
</div>
<SheetFooter className="flex-shrink-0 flex-row justify-end gap-2 border-t border-border bg-[var(--surface)] px-4 py-3 pb-[calc(12px+var(--app-safe-bottom,env(safe-area-inset-bottom,0px)))]">
<Button
onClick={onReassign}
disabled={
saving ||
selectedUserId === "" ||
selectedUserId === selectedDuty?.user_id ||
users.length === 0
}
aria-describedby={reassignErrorKey ? "admin-reassign-error" : undefined}
>
{saving ? t("loading") : t("admin.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,5 @@
export * from "./use-admin-access";
export * from "./use-admin-users";
export * from "./use-admin-duties";
export * from "./use-infinite-duty-groups";
export * from "./use-admin-reassign";

View File

@@ -0,0 +1,44 @@
"use client";
import { useEffect, useState } from "react";
import { fetchAdminMe } from "@/lib/api";
export interface UseAdminAccessOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
}
export function useAdminAccess({ isAllowed, initDataRaw, lang }: UseAdminAccessOptions) {
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
useEffect(() => {
if (!isAllowed || !initDataRaw) return;
setAdminCheckComplete(null);
setAdminAccessDenied(false);
setAdminAccessDeniedDetail(null);
fetchAdminMe(initDataRaw, lang)
.then(({ is_admin }) => {
if (!is_admin) {
setAdminAccessDenied(true);
setAdminCheckComplete(false);
return;
}
setAdminCheckComplete(true);
})
.catch(() => {
setAdminAccessDenied(true);
setAdminCheckComplete(false);
});
}, [isAllowed, initDataRaw, lang]);
return {
adminCheckComplete,
adminAccessDenied,
adminAccessDeniedDetail,
setAdminAccessDenied,
setAdminAccessDeniedDetail,
};
}

View File

@@ -0,0 +1,49 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { fetchDuties } from "@/lib/api";
import type { DutyWithUser } from "@/types";
import { firstDayOfMonth, lastDayOfMonth, localDateString } from "@/lib/date-utils";
export interface UseAdminDutiesOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
adminCheckComplete: boolean | null;
adminMonth: Date;
onError: (message: string) => void;
clearError: () => void;
}
export function useAdminDuties({
isAllowed,
initDataRaw,
lang,
adminCheckComplete,
adminMonth,
onError,
clearError,
}: UseAdminDutiesOptions) {
const [duties, setDuties] = useState<DutyWithUser[]>([]);
const [loadingDuties, setLoadingDuties] = useState(true);
const from = useMemo(() => localDateString(firstDayOfMonth(adminMonth)), [adminMonth]);
const to = useMemo(() => localDateString(lastDayOfMonth(adminMonth)), [adminMonth]);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingDuties(true);
clearError();
fetchDuties(from, to, initDataRaw, lang, controller.signal)
.then((list) => setDuties(list))
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
onError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingDuties(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete, onError, clearError]);
return { duties, setDuties, loadingDuties, from, to };
}

View File

@@ -0,0 +1,116 @@
"use client";
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { AccessDeniedError, patchAdminDuty, type UserForAdmin } from "@/lib/api";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import type { DutyWithUser } from "@/types";
export interface UseAdminReassignOptions {
initDataRaw: string | undefined;
lang: "ru" | "en";
users: UserForAdmin[];
setDuties: Dispatch<SetStateAction<DutyWithUser[]>>;
t: (key: string, params?: Record<string, string>) => string;
}
export function useAdminReassign({
initDataRaw,
lang,
users,
setDuties,
t,
}: UseAdminReassignOptions) {
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignErrorKey, setReassignErrorKey] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [sheetExiting, setSheetExiting] = useState(false);
const closeReassign = useCallback(() => {
setSelectedDuty(null);
setSelectedUserId("");
setReassignErrorKey(null);
setSheetExiting(false);
}, []);
useEffect(() => {
if (!sheetExiting) return;
const fallback = window.setTimeout(() => {
closeReassign();
}, 320);
return () => window.clearTimeout(fallback);
}, [sheetExiting, closeReassign]);
const openReassign = useCallback((duty: DutyWithUser) => {
setSelectedDuty(duty);
setSelectedUserId(duty.user_id);
setReassignErrorKey(null);
}, []);
const requestCloseSheet = useCallback(() => {
setSheetExiting(true);
}, []);
const handleReassign = useCallback(() => {
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
if (selectedUserId === selectedDuty.user_id) {
closeReassign();
return;
}
setSaving(true);
setReassignErrorKey(null);
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
.then((updated) => {
setDuties((prev) =>
prev.map((d) =>
d.id === updated.id
? {
...d,
user_id: updated.user_id,
full_name:
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
}
: d
)
);
setSuccessMessage(t("admin.reassign_success"));
try {
triggerHapticLight();
} catch {
// Haptic not available (e.g. non-Telegram).
}
requestCloseSheet();
setTimeout(() => setSuccessMessage(null), 3000);
})
.catch((e) => {
if (e instanceof AccessDeniedError) {
setReassignErrorKey("admin.reassign_error_denied");
} else if (e instanceof Error && /not found|не найден/i.test(e.message)) {
setReassignErrorKey("admin.reassign_error_not_found");
} else if (
e instanceof TypeError ||
(e instanceof Error && (e.message === "Failed to fetch" || e.message === "Load failed"))
) {
setReassignErrorKey("admin.reassign_error_network");
} else {
setReassignErrorKey("admin.reassign_error_generic");
}
})
.finally(() => setSaving(false));
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t, setDuties]);
return {
selectedDuty,
selectedUserId,
setSelectedUserId,
saving,
reassignErrorKey,
successMessage,
sheetExiting,
openReassign,
requestCloseSheet,
handleReassign,
closeReassign,
};
}

View File

@@ -0,0 +1,45 @@
"use client";
import { useEffect, useState } from "react";
import { AccessDeniedError, fetchAdminUsers, type UserForAdmin } from "@/lib/api";
export interface UseAdminUsersOptions {
isAllowed: boolean;
initDataRaw: string | undefined;
lang: "ru" | "en";
adminCheckComplete: boolean | null;
onAccessDenied: (detail: string | null) => void;
onError: (message: string) => void;
}
export function useAdminUsers({
isAllowed,
initDataRaw,
lang,
adminCheckComplete,
onAccessDenied,
onError,
}: UseAdminUsersOptions) {
const [users, setUsers] = useState<UserForAdmin[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingUsers(true);
fetchAdminUsers(initDataRaw, lang, controller.signal)
.then((list) => setUsers(list))
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
if (e instanceof AccessDeniedError) {
onAccessDenied(e.serverDetail ?? null);
return;
}
onError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingUsers(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, adminCheckComplete, onAccessDenied, onError]);
return { users, setUsers, loadingUsers };
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { DutyWithUser } from "@/types";
import { localDateString } from "@/lib/date-utils";
export const ADMIN_PAGE_SIZE = 20;
export function useInfiniteDutyGroups(duties: DutyWithUser[], from: string, to: string) {
const [visibleCount, setVisibleCount] = useState(ADMIN_PAGE_SIZE);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const dutyOnly = useMemo(() => {
const now = new Date();
return duties
.filter((d) => d.event_type === "duty" && new Date(d.end_at) > now)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
}, [duties]);
useEffect(() => {
setVisibleCount(ADMIN_PAGE_SIZE);
}, [from, to]);
const visibleDuties = useMemo(() => dutyOnly.slice(0, visibleCount), [dutyOnly, visibleCount]);
const hasMore = visibleCount < dutyOnly.length;
const visibleGroups = useMemo(() => {
const map = new Map<string, DutyWithUser[]>();
for (const d of visibleDuties) {
const key = localDateString(new Date(d.start_at));
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(d);
}
return Array.from(map.entries()).map(([dateKey, items]) => ({ dateKey, duties: items }));
}, [visibleDuties]);
useEffect(() => {
if (!hasMore || !sentinelRef.current) return;
const el = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisibleCount((prev) => Math.min(prev + ADMIN_PAGE_SIZE, dutyOnly.length));
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, dutyOnly.length]);
return { dutyOnly, visibleDuties, visibleGroups, hasMore, sentinelRef };
}

View File

@@ -0,0 +1,9 @@
/**
* Admin feature: hook and presentational components for the admin page.
*/
export { useAdminPage } from "./useAdminPage";
export { AdminDutyList } from "./AdminDutyList";
export { ReassignSheet } from "./ReassignSheet";
export type { AdminDutyListProps, AdminDutyGroup } from "./AdminDutyList";
export type { ReassignSheetProps } from "./ReassignSheet";

View File

@@ -0,0 +1,129 @@
/**
* Admin page composition hook.
* Delegates access/users/duties/reassign/infinite-list concerns to focused hooks.
*/
"use client";
import { useState, useCallback, useRef, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useTranslation } from "@/i18n/use-translation";
import { useTelegramBackButton, useTelegramClosingConfirmation } from "@/hooks/telegram";
import { ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW } from "@/lib/telegram-interaction-policy";
import {
useAdminAccess,
useAdminUsers,
useAdminDuties,
useInfiniteDutyGroups,
useAdminReassign,
} from "@/components/admin/hooks";
import { useRequestState } from "@/hooks/use-request-state";
export function useAdminPage() {
const router = useRouter();
const { sdkReady } = useTelegramSdkReady();
const { initDataRaw, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
const currentMonth = useAppStore((s) => s.currentMonth);
const { t } = useTranslation();
/** Local month for admin view; does not change global calendar month. */
const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1));
const navigateHomeRef = useRef(() => router.push("/"));
navigateHomeRef.current = () => router.push("/");
const request = useRequestState("idle");
const access = useAdminAccess({ isAllowed, initDataRaw, lang });
const handleAdminAccessDenied = useCallback(
(detail: string | null) => {
access.setAdminAccessDenied(true);
access.setAdminAccessDeniedDetail(detail);
},
[access.setAdminAccessDenied, access.setAdminAccessDeniedDetail]
);
const onPrevMonth = useCallback(() => {
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
}, []);
const onNextMonth = useCallback(() => {
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
}, []);
useTelegramBackButton({
enabled: sdkReady && !isLocalhost,
onClick: () => navigateHomeRef.current(),
});
const usersRequest = useAdminUsers({
isAllowed,
initDataRaw,
lang,
adminCheckComplete: access.adminCheckComplete,
onAccessDenied: handleAdminAccessDenied,
onError: request.setError,
});
const dutiesRequest = useAdminDuties({
isAllowed,
initDataRaw,
lang,
adminCheckComplete: access.adminCheckComplete,
adminMonth,
onError: request.setError,
clearError: request.reset,
});
const reassign = useAdminReassign({
initDataRaw,
lang,
users: usersRequest.users,
setDuties: dutiesRequest.setDuties,
t,
});
useTelegramClosingConfirmation(
ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW && reassign.selectedDuty !== null
);
const list = useInfiniteDutyGroups(dutiesRequest.duties, dutiesRequest.from, dutiesRequest.to);
const usersForSelect = useMemo(
() => usersRequest.users.filter((u) => u.role_id === 1 || u.role_id === 2),
[usersRequest.users]
);
const error = request.state.error;
const loading = usersRequest.loadingUsers || dutiesRequest.loadingDuties;
return {
isAllowed,
adminCheckComplete: access.adminCheckComplete,
adminAccessDenied: access.adminAccessDenied,
adminAccessDeniedDetail: access.adminAccessDeniedDetail,
error,
loading,
adminMonth,
onPrevMonth,
onNextMonth,
successMessage: reassign.successMessage,
dutyOnly: list.dutyOnly,
usersForSelect,
visibleDuties: list.visibleDuties,
visibleGroups: list.visibleGroups,
hasMore: list.hasMore,
sentinelRef: list.sentinelRef,
selectedDuty: reassign.selectedDuty,
selectedUserId: reassign.selectedUserId,
setSelectedUserId: reassign.setSelectedUserId,
saving: reassign.saving,
reassignErrorKey: reassign.reassignErrorKey,
sheetExiting: reassign.sheetExiting,
openReassign: reassign.openReassign,
requestCloseSheet: reassign.requestCloseSheet,
handleReassign: reassign.handleReassign,
closeReassign: reassign.closeReassign,
};
}

View File

@@ -0,0 +1,95 @@
/**
* 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");
});
it("applies today base and holiday outline when isToday and eventSummaries are set", () => {
render(
<CalendarDay
{...defaultProps}
isOtherMonth={false}
isToday={true}
eventSummaries={["Holiday"]}
onDayClick={() => {}}
/>
);
const button = screen.getByRole("button", { name: /15/ });
expect(button.className).toMatch(/bg-today|today/);
expect(button.className).toMatch(/ring-2|today-holiday-outline/);
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 isTodayHoliday = isToday && hasEvent;
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 &&
!isToday &&
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
isTodayHoliday && "ring-1 ring-inset ring-[var(--today-holiday-outline)]"
)}
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,95 @@
/**
* 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";
import { useTranslation } from "@/i18n/use-translation";
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 { t } = useTranslation();
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={t("aria.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,51 @@
/**
* Calendar header: month title, prev/next navigation, weekday labels.
* Replaces the header from webapp index.html and calendar.js month title.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
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 { weekdayLabels } = useTranslation();
const labels = weekdayLabels();
return (
<header className={cn("flex flex-col", className)}>
<MonthNavHeader
month={month}
disabled={disabled}
onPrevMonth={onPrevMonth}
onNextMonth={onNextMonth}
ariaLive
className="mb-3"
/>
<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,86 @@
/**
* Shared month navigation row: prev button, year + month title, next button.
* Used by CalendarHeader and admin page for consistent layout and spacing.
*/
"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 MonthNavHeaderProps {
/** Currently displayed month (used for title). */
month: Date;
/** Whether month navigation is disabled (e.g. during loading). */
disabled?: boolean;
onPrevMonth: () => void;
onNextMonth: () => void;
/** Optional aria-label for the month title (e.g. admin page). */
titleAriaLabel?: string;
/** When true, title is announced on change (e.g. calendar). */
ariaLive?: boolean;
className?: string;
}
const NAV_BUTTON_CLASS =
"size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50";
export function MonthNavHeader({
month,
disabled = false,
onPrevMonth,
onNextMonth,
titleAriaLabel,
ariaLive = false,
className,
}: MonthNavHeaderProps) {
const { t, monthName } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
return (
<div className={cn("flex items-center justify-between", className)}>
<Button
type="button"
variant="secondary"
size="icon"
className={NAV_BUTTON_CLASS}
aria-label={t("nav.prev_month")}
disabled={disabled}
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
<h1
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
{...(titleAriaLabel ? { "aria-label": titleAriaLabel } : {})}
{...(ariaLive ? { "aria-live": "polite", "aria-atomic": true } : {})}
>
<span className="text-xs font-normal leading-none text-muted">
{year}
</span>
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
{monthName(monthIndex)}
</span>
</h1>
</div>
<Button
type="button"
variant="secondary"
size="icon"
className={NAV_BUTTON_CLASS}
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</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,261 @@
/**
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { TooltipProvider } from "@/components/ui/tooltip";
import { resetAppStore } from "@/test/test-utils";
function renderWithTooltip(ui: React.ReactElement) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
const openPhoneLinkMock = vi.fn();
const openTelegramProfileMock = vi.fn();
const triggerHapticLightMock = vi.fn();
const copyToClipboardMock = vi.fn();
vi.mock("@/lib/open-phone-link", () => ({
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
}));
vi.mock("@/lib/telegram-link", () => ({
openTelegramProfile: (...args: unknown[]) => openTelegramProfileMock(...args),
}));
vi.mock("@/lib/telegram-haptic", () => ({
triggerHapticLight: () => triggerHapticLightMock(),
}));
vi.mock("@/lib/copy-to-clipboard", () => ({
copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args),
}));
describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
openPhoneLinkMock.mockClear();
openTelegramProfileMock.mockClear();
triggerHapticLightMock.mockClear();
copyToClipboardMock.mockClear();
});
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");
});
it("calls openPhoneLink and triggerHapticLight when phone link is clicked", () => {
render(
<ContactLinks phone="+79991234567" username={null} showLabels={false} />
);
const telLink = document.querySelector<HTMLAnchorElement>('a[href^="tel:"]');
expect(telLink).toBeInTheDocument();
fireEvent.click(telLink!);
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
it("calls openTelegramProfile and triggerHapticLight when Telegram link is clicked", () => {
render(
<ContactLinks phone={null} username="alice_dev" showLabels={false} />
);
const tgLink = document.querySelector<HTMLAnchorElement>('a[href*="t.me"]');
expect(tgLink).toBeInTheDocument();
fireEvent.click(tgLink!);
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
describe("showCopyButtons with layout block", () => {
it("renders copy phone button with aria-label when phone is present", () => {
renderWithTooltip(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
});
it("renders copy Telegram button with aria-label when username is present", () => {
renderWithTooltip(
<ContactLinks
phone={null}
username="alice_dev"
layout="block"
showCopyButtons
/>
);
expect(
screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
})
).toBeInTheDocument();
});
it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => {
copyToClipboardMock.mockResolvedValue(true);
renderWithTooltip(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
it("calls copyToClipboard with @username when copy Telegram is clicked", async () => {
copyToClipboardMock.mockResolvedValue(true);
renderWithTooltip(
<ContactLinks
phone={null}
username="alice_dev"
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
it("shows Copied via button aria-label and no tooltip after successful copy", async () => {
copyToClipboardMock.mockResolvedValue(true);
render(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
});
it("reverts first copy button to Copy icon when copying the other field", async () => {
copyToClipboardMock.mockResolvedValue(true);
render(
<ContactLinks
phone="+79991234567"
username="alice_dev"
layout="block"
showCopyButtons
/>
);
const copyPhoneBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
const copyTelegramBtn = screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
});
await act(async () => {
fireEvent.click(copyPhoneBtn);
});
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
await act(async () => {
fireEvent.click(copyTelegramBtn);
});
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
});
it("does not show copy buttons when showCopyButtons is false", () => {
render(
<ContactLinks
phone="+79991234567"
username="bob"
layout="block"
showCopyButtons={false}
/>
);
expect(
screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i })
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,247 @@
/**
* Contact links (phone, Telegram) for duty cards and day detail.
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
*/
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { openPhoneLink } from "@/lib/open-phone-link";
import { openTelegramProfile } from "@/lib/telegram-link";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon, Copy, Check } from "lucide-react";
const COPIED_RESET_MS = 1800;
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;
/** When true and layout is "block", show copy buttons for phone and Telegram username. */
showCopyButtons?: boolean;
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,
showCopyButtons = false,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
const showCopy = layout === "block" && showCopyButtons;
useEffect(() => () => clearCopiedTimeout(), []);
const clearCopiedTimeout = () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = null;
}
};
const showCopiedFeedback = (kind: "phone" | "telegram") => {
clearCopiedTimeout();
setCopiedKind(kind);
copiedTimeoutRef.current = setTimeout(() => {
setCopiedKind(null);
copiedTimeoutRef.current = null;
}, COPIED_RESET_MS);
};
if (!hasPhone && !hasUsername) return null;
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
openPhoneLink(phone ?? undefined);
triggerHapticLight();
};
const handleCopyPhone = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
triggerHapticLight();
const rawPhone = String(phone).trim();
const ok = await copyToClipboard(rawPhone);
if (ok) showCopiedFeedback("phone");
};
const handleCopyTelegram = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
triggerHapticLight();
const text = `@${cleanUsername}`;
const ok = await copyToClipboard(text);
if (ok) showCopiedFeedback("telegram");
};
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") {
const rowClass =
"flex h-12 items-center gap-0 rounded-md border border-input bg-background shadow-xs text-accent hover:bg-accent/10 hover:text-accent dark:bg-input/30 dark:hover:bg-input/50";
return (
<div className={cn("flex flex-col gap-2", className)}>
{hasPhone && (
<div className={rowClass}>
<a
href={`tel:${String(phone).trim()}`}
aria-label={ariaCall}
onClick={handlePhoneClick}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
>
<PhoneIcon className="size-5 shrink-0" aria-hidden />
<span className="truncate">{formatPhoneDisplay(phone!)}</span>
</a>
{showCopy && (
<button
type="button"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
aria-label={copiedKind === "phone" ? t("contact.copied") : t("contact.copy_phone")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleCopyPhone(e);
}}
>
{copiedKind === "phone" ? (
<Check className="size-5" aria-hidden />
) : (
<Copy className="size-5" aria-hidden />
)}
</button>
)}
</div>
)}
{hasUsername && (
<div className={rowClass}>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
onClick={(e) => {
e.preventDefault();
openTelegramProfile(cleanUsername);
triggerHapticLight();
}}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
>
<TelegramIcon className="size-5 shrink-0" aria-hidden />
<span className="truncate">@{cleanUsername}</span>
</a>
{showCopy && (
<button
type="button"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
aria-label={copiedKind === "telegram" ? t("contact.copied") : t("contact.copy_telegram")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleCopyTelegram(e);
}}
>
{copiedKind === "telegram" ? (
<Check className="size-5" aria-hidden />
) : (
<Copy className="size-5" aria-hidden />
)}
</button>
)}
</div>
)}
</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}
onClick={handlePhoneClick}
>
{displayPhone}
</a>
</span>
) : (
<a
key="phone"
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
{displayPhone}
</a>
)
);
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
openTelegramProfile(cleanUsername);
triggerHapticLight();
};
const link = (
<a
key="tg"
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
onClick={handleTelegramClick}
>
@{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,185 @@
/**
* 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 { TooltipProvider } from "@/components/ui/tooltip";
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 Open calendar button in no-duty view and it calls onBack", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
const openCalendarBtn = screen.getByRole("button", {
name: /Open calendar|Открыть календарь/i,
});
expect(openCalendarBtn).toBeInTheDocument();
fireEvent.click(openCalendarBtn);
expect(onBack).toHaveBeenCalled();
});
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([]);
});
it("shows ends_at line when duty is active", async () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000);
const end = new Date(now.getTime() + 2 * 60 * 60 * 1000);
const duty = {
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([duty]);
render(<CurrentDutyView onBack={vi.fn()} />);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByText(/Until end of shift at|До конца смены в/i)
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
it("error state shows Retry as first button", async () => {
const { fetchDuties } = await import("@/lib/api");
vi.mocked(fetchDuties).mockRejectedValue(new Error("Network error"));
render(<CurrentDutyView onBack={vi.fn()} />);
await screen.findByText(/Could not load|Не удалось загрузить/i, {}, { timeout: 3000 });
const buttons = screen.getAllByRole("button");
expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i);
vi.mocked(fetchDuties).mockResolvedValue([]);
});
it("403 shows AccessDeniedScreen with Back button and no Retry", async () => {
const { fetchDuties, AccessDeniedError } = await import("@/lib/api");
vi.mocked(fetchDuties).mockRejectedValue(
new AccessDeniedError("ACCESS_DENIED", "Custom 403 message")
);
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 3000 });
expect(
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
).toBeInTheDocument();
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i })
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Retry|Повторить/i })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i }));
expect(onBack).toHaveBeenCalled();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
it("shows copy phone and copy Telegram buttons when duty has contacts", async () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000);
const end = new Date(now.getTime() + 60 * 60 * 1000);
const dutyWithContacts = {
id: 1,
user_id: 1,
start_at: start.toISOString(),
end_at: end.toISOString(),
event_type: "duty" as const,
full_name: "Test User",
phone: "+79991234567",
username: "testuser",
};
vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]);
render(
<TooltipProvider>
<CurrentDutyView onBack={vi.fn()} />
</TooltipProvider>
);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
})
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
});

View File

@@ -0,0 +1,357 @@
/**
* 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 { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { translate } from "@/i18n/messages";
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 { triggerHapticLight } from "@/lib/telegram-haptic";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import type { DutyWithUser } from "@/types";
import { useTelegramBackButton, useTelegramCloseAction } from "@/hooks/telegram";
import { useScreenReady } from "@/hooks/use-screen-ready";
import { useRequestState } from "@/hooks/use-request-state";
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;
}
/**
* 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 { initDataRaw } = useTelegramAuth();
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const {
state: requestState,
setLoading,
setSuccess,
setError,
setAccessDenied,
isLoading,
isError,
isAccessDenied,
} = useRequestState("loading");
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);
setSuccess();
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
setRemaining(null);
}
} catch (e) {
if (signal?.aborted) return;
if (e instanceof AccessDeniedError) {
setAccessDenied(e.serverDetail ?? null);
setDuty(null);
setRemaining(null);
} else {
setError(translate(lang, "error_generic"));
setDuty(null);
setRemaining(null);
}
}
},
[initDataRaw, lang, setSuccess, setAccessDenied, setError]
);
// 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]);
useScreenReady(!isLoading);
// 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]);
useTelegramBackButton({
enabled: true,
onClick: onBack,
});
const handleBack = () => {
triggerHapticLight();
onBack();
};
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
const handleClose = () => {
triggerHapticLight();
closeMiniAppOrFallback();
};
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 (isLoading) {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-7 w-48" />
<Skeleton className="h-4 w-full max-w-[200px]" />
<Skeleton className="h-4 w-full max-w-[280px]" />
</div>
<Skeleton className="h-14 w-full rounded-lg" />
<div className="flex flex-col gap-2 border-t border-border/50 pt-4">
<Skeleton className="h-12 w-full rounded-md" />
<Skeleton className="h-12 w-full rounded-md" />
</div>
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (isAccessDenied) {
return (
<AccessDeniedScreen
serverDetail={requestState.accessDeniedDetail}
primaryAction="back"
onBack={handlePrimaryAction}
openedFromPin={openedFromPin}
/>
);
}
if (isError) {
const handleRetry = () => {
triggerHapticLight();
setLoading();
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6">
<p className="text-error">{requestState.error}</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">
<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 className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
<Button
variant="outline"
onClick={onBack}
aria-label={t("current_duty.open_calendar")}
>
{t("current_duty.open_calendar")}
</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 remainingValueStr = t("current_duty.remaining_value", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
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">
<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 className="sr-only">
<CardTitle id="current-duty-title">{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-6">
<span
className="inline-flex w-fit items-center gap-2 rounded-full bg-duty/15 px-2.5 py-1 text-xs font-medium text-foreground"
aria-hidden
>
<span className="size-2 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none" />
{t("duty.now_on_duty")}
</span>
<section className="flex flex-col gap-2" aria-label={t("current_duty.shift")}>
<p
className="text-xl font-bold text-foreground leading-tight"
id="current-duty-name"
>
{duty.full_name}
</p>
<p className="text-sm text-muted-foreground break-words">
{shiftLabel}
</p>
<p className="text-sm text-muted-foreground break-words">
{shiftStr}
</p>
</section>
<div
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
aria-label={t("current_duty.remaining_label")}
>
<span className="text-xs text-muted-foreground">
{t("current_duty.remaining_label")}
</span>
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
{remainingValueStr}
</span>
<span className="text-xs text-muted-foreground">
{t("current_duty.ends_at", { time: formatHHMM(duty.end_at) })}
</span>
</div>
<section className="flex flex-col gap-2 border-t border-border/50 pt-4" aria-label={t("contact.label")}>
{hasContacts ? (
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
showCopyButtons={true}
/>
) : (
<p className="text-sm text-muted-foreground">
{t("current_duty.contact_info_not_set")}
</p>
)}
</section>
</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,258 @@
/**
* 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 - var(--app-safe-left, 0) - var(--app-safe-right, 0) - 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 max-h-[70vh] bg-[var(--surface)]",
className
)}
overlayClassName="backdrop-blur-md"
showCloseButton={false}
closeLabel={t("day_detail.close")}
onCloseAnimationEnd={handleClose}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<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>
);
}

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