22 Commits

Author SHA1 Message Date
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
90 changed files with 4311 additions and 495 deletions

View File

@@ -27,6 +27,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
| 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 |
@@ -40,8 +41,11 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
- **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
@@ -50,4 +54,17 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
- **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.

View File

@@ -24,6 +24,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
| 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
@@ -35,6 +36,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
## 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`.
@@ -48,4 +50,5 @@ Docstrings and code comments must be in English (Google-style docstrings). UI st
- **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,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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.)
@@ -52,7 +64,10 @@ 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.0.3...HEAD
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.1...HEAD
[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 -->

View File

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

@@ -6,6 +6,7 @@ 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).

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

@@ -6,7 +6,7 @@ from datetime import date, timedelta
import duty_teller.config as config
from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, Response
from fastapi.staticfiles import StaticFiles
@@ -14,19 +14,34 @@ 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__)
@@ -154,7 +169,8 @@ def app_config_js() -> Response:
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}'
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,
@@ -283,6 +299,96 @@ def get_personal_calendar_ical(
)
# --- 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

@@ -12,6 +12,7 @@ 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
@@ -159,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

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

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

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

@@ -88,6 +88,7 @@ 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.",
@@ -98,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 для списка команд.",
@@ -174,6 +178,7 @@ 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": "Диапазон дат слишком большой. Запросите более короткий период.",
@@ -184,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

@@ -19,6 +19,12 @@ from duty_teller.utils.http_client import safe_urlopen
_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()."""
if not config.BOT_USERNAME:
@@ -98,13 +104,7 @@ def main() -> None:
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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "duty-teller"
version = "2.0.3"
version = "2.1.1"
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

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

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

View File

@@ -5,8 +5,13 @@
"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,
@@ -16,6 +21,11 @@ export default function GlobalError({
reset: () => void;
}) {
const lang = getLang();
useEffect(() => {
callMiniAppReadyOnce();
}, []);
return (
<html
lang={lang === "ru" ? "ru" : "en"}
@@ -23,29 +33,22 @@ export default function GlobalError({
suppressHydrationWarning
>
<head>
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
</head>
<body className="antialiased">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")}
</h1>
<p className="text-center text-muted-foreground">
{translate(lang, "error_boundary.description")}
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{translate(lang, "error_boundary.reload")}
</button>
</div>
<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

@@ -89,6 +89,11 @@
--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). */
@@ -174,7 +179,7 @@ html::-webkit-scrollbar {
margin-right: auto;
padding: 12px;
padding-top: 0;
padding-bottom: env(safe-area-inset-bottom, 12px);
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
border-radius: 12px;
}
@@ -261,9 +266,27 @@ html::-webkit-scrollbar {
}
}
/* 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: env(safe-area-inset-top, 0);
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). */
@@ -305,7 +328,7 @@ html::-webkit-scrollbar {
body {
margin: 0;
padding: 0;
min-height: 100vh;
min-height: var(--tg-viewport-stable-height, 100vh);
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;

View File

@@ -1,7 +1,9 @@
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 = {
@@ -23,12 +25,7 @@ export default function RootLayout({
return (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
<script
dangerouslySetInnerHTML={{
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
@@ -39,7 +36,9 @@ export default function RootLayout({
<body className="antialiased">
<TelegramProvider>
<AppErrorBoundary>
<TooltipProvider>{children}</TooltipProvider>
<TooltipProvider>
<AppShell>{children}</AppShell>
</TooltipProvider>
</AppErrorBoundary>
</TelegramProvider>
</body>

View File

@@ -7,19 +7,24 @@
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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p>
<Link
href="/"
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{t("not_found.open_calendar")}
</Link>
</div>
<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

@@ -4,17 +4,17 @@
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, act } from "@testing-library/react";
import Page from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
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: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
useTelegramAuth: vi.fn(),
}));
vi.mock("@/hooks/use-month-data", () => ({
@@ -26,6 +26,11 @@ vi.mock("@/hooks/use-month-data", () => ({
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 () => {
@@ -35,20 +40,26 @@ describe("Page", () => {
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
});
it("sets document title and lang from store lang", async () => {
useAppStore.getState().setLang("en");
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
expect(document.title).toBe("Duty Calendar");
expect(document.documentElement.lang).toBe("en");
});
it("sets document title for ru when store lang is ru", async () => {
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
await waitFor(() => {
expect(document.title).toBe("Календарь дежурств");
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

@@ -6,26 +6,37 @@
"use client";
import { useCallback, useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { useAppStore, type AppState } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { 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() {
useTelegramTheme();
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
useAppInit({ isAllowed, startParam });
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
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) => ({
useShallow((s: AppState) => ({
accessDenied: s.accessDenied,
currentView: s.currentView,
setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay,
@@ -33,35 +44,37 @@ export default function Home() {
}))
);
// When content is ready, tell Telegram to hide native loading and show our app.
useEffect(() => {
if (appContentReady) {
callMiniAppReadyOnce();
}
}, [appContentReady]);
useScreenReady(accessDenied || currentView === "currentDuty");
const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar");
setSelectedDay(null);
}, [setCurrentView, setSelectedDay]);
const content =
currentView === "currentDuty" ? (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
const content = accessDenied ? (
<MiniAppScreen>
<MiniAppScreenContent>
<AccessDeniedScreen primaryAction="reload" />
</MiniAppScreenContent>
</MiniAppScreen>
) : currentView === "currentDuty" ? (
<MiniAppScreen>
<MiniAppScreenContent>
<CurrentDutyView
onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"}
/>
</div>
) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
);
</MiniAppScreenContent>
</MiniAppScreen>
) : (
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
);
return (
<div
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
style={{
visibility: appContentReady ? "visible" : "hidden",
minHeight: "100vh",
}}
>
{content}

View File

@@ -7,9 +7,9 @@
"use client";
import React from "react";
import { getLang } from "@/i18n/messages";
import { translate } from "@/i18n/messages";
import { getLang, translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
interface AppErrorBoundaryProps {
children: React.ReactNode;
@@ -52,23 +52,18 @@ export class AppErrorBoundary extends React.Component<
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 (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
role="alert"
>
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
<Button
type="button"
variant="default"
size="sm"
onClick={this.handleReload}
className="bg-primary text-primary-foreground hover:opacity-90"
>
{reloadLabel}
</Button>
</div>
<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

@@ -6,6 +6,7 @@
"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";
@@ -17,7 +18,10 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied";
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;
@@ -51,15 +55,14 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
loading,
error,
accessDenied,
accessDeniedDetail,
duties,
calendarEvents,
selectedDay,
isAdmin,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
setAppContentReady,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
@@ -67,18 +70,19 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
accessDeniedDetail: s.accessDeniedDetail,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
isAdmin: s.isAdmin,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
setAppContentReady: s.setAppContentReady,
}))
);
const router = useRouter();
const { retry } = useMonthData({
initDataRaw,
enabled: isAllowed,
@@ -107,6 +111,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
{ threshold: 50, disabled: navDisabled }
);
useStickyScroll(calendarStickyRef);
useTelegramVerticalSwipePolicy(DISABLE_VERTICAL_SWIPES_BY_DEFAULT);
const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => {
@@ -126,54 +131,51 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
setSelectedDay(null);
}, [setSelectedDay]);
const readyCalledRef = useRef(false);
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
useEffect(() => {
if ((!loading || accessDenied) && !readyCalledRef.current) {
readyCalledRef.current = true;
setAppContentReady(true);
}
}, [loading, accessDenied, setAppContentReady]);
useScreenReady(!loading || accessDenied);
useTelegramSettingsButton({
enabled: isAdmin,
onClick: () => router.push("/admin"),
});
return (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
<div
ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
>
<CalendarHeader
month={currentMonth}
disabled={navDisabled}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
<CalendarGrid
currentMonth={currentMonth}
<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}
onDayClick={handleDayClick}
onClose={handleCloseDayDetail}
/>
</div>
{accessDenied && (
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
)}
{!accessDenied && error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!accessDenied && !error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
</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

@@ -15,6 +15,7 @@ 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. */
@@ -37,6 +38,7 @@ export function CalendarGrid({
onDayClick,
className,
}: CalendarGridProps) {
const { t } = useTranslation();
const dutiesByDateMap = useMemo(
() => dutiesByDate(duties),
[duties]
@@ -67,7 +69,7 @@ export function CalendarGrid({
className
)}
role="grid"
aria-label="Calendar"
aria-label={t("aria.calendar")}
>
{cells.map(({ date, key, month }, i) => {
const isOtherMonth = month !== currentMonth.getMonth();

View File

@@ -5,13 +5,9 @@
"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";
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
export interface CalendarHeaderProps {
/** Currently displayed month (used for title). */
@@ -30,51 +26,19 @@ export function CalendarHeader({
onNextMonth,
className,
}: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
const { weekdayLabels } = useTranslation();
const labels = weekdayLabels();
return (
<header className={cn("flex flex-col", className)}>
<div className="flex items-center justify-between mb-3">
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.prev_month")}
disabled={disabled}
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex 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"
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="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
<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>

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

@@ -3,14 +3,31 @@
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { resetAppStore } from "@/test/test-utils";
const openPhoneLinkMock = vi.fn();
const openTelegramProfileMock = vi.fn();
const triggerHapticLightMock = 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(),
}));
describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
openPhoneLinkMock.mockClear();
openTelegramProfileMock.mockClear();
triggerHapticLightMock.mockClear();
});
it("returns null when phone and username are missing", () => {
@@ -57,4 +74,30 @@ describe("ContactLinks", () => {
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();
});
});

View File

@@ -7,6 +7,9 @@
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 { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
@@ -43,6 +46,12 @@ export function ContactLinks({
if (!hasPhone && !hasUsername) return null;
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
openPhoneLink(phone ?? undefined);
triggerHapticLight();
};
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
@@ -60,7 +69,11 @@ export function ContactLinks({
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<a
href={`tel:${String(phone).trim()}`}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
@@ -78,6 +91,11 @@ export function ContactLinks({
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
onClick={(e) => {
e.preventDefault();
openTelegramProfile(cleanUsername);
triggerHapticLight();
}}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
@@ -99,6 +117,7 @@ export function ContactLinks({
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
{displayPhone}
</a>
@@ -109,6 +128,7 @@ export function ContactLinks({
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
onClick={handlePhoneClick}
>
{displayPhone}
</a>
@@ -117,6 +137,11 @@ export function ContactLinks({
}
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"
@@ -125,6 +150,7 @@ export function ContactLinks({
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
onClick={handleTelegramClick}
>
@{cleanUsername}
</a>

View File

@@ -127,4 +127,25 @@ describe("CurrentDutyView", () => {
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([]);
});
});

View File

@@ -8,9 +8,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { translate } from "@/i18n/messages";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { fetchDuties, AccessDeniedError } from "@/lib/api";
@@ -20,6 +20,7 @@ import {
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 {
@@ -30,7 +31,11 @@ import {
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). */
@@ -39,21 +44,26 @@ export interface CurrentDutyViewProps {
openedFromPin?: boolean;
}
type ViewState = "loading" | "error" | "ready";
/**
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
*/
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation();
const lang = useAppStore((s) => s.lang);
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading");
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const {
state: requestState,
setLoading,
setSuccess,
setError,
setAccessDenied,
isLoading,
isError,
isAccessDenied,
} = useRequestState("loading");
const loadTodayDuties = useCallback(
async (signal?: AbortSignal | null) => {
@@ -66,7 +76,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (signal?.aborted) return;
const active = findCurrentDuty(duties);
setDuty(active);
setState("ready");
setSuccess();
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
@@ -74,17 +84,18 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
}
} catch (e) {
if (signal?.aborted) return;
setState("error");
const msg =
e instanceof AccessDeniedError && e.serverDetail
? e.serverDetail
: t("error_generic");
setErrorMessage(msg);
setDuty(null);
setRemaining(null);
if (e instanceof AccessDeniedError) {
setAccessDenied(e.serverDetail ?? null);
setDuty(null);
setRemaining(null);
} else {
setError(translate(lang, "error_generic"));
setDuty(null);
setRemaining(null);
}
}
},
[initDataRaw, lang, t]
[initDataRaw, lang, setSuccess, setAccessDenied, setError]
);
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
@@ -94,12 +105,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => controller.abort();
}, [loadTodayDuties]);
// Mark content ready when data is loaded or error, so page can call ready() and show content.
useEffect(() => {
if (state !== "loading") {
setAppContentReady(true);
}
}, [state, setAppContentReady]);
useScreenReady(!isLoading);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
@@ -110,45 +116,21 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
return () => clearInterval(interval);
}, [duty]);
// Telegram BackButton: show on mount, hide on unmount, handle click.
useEffect(() => {
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onBack);
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [onBack]);
useTelegramBackButton({
enabled: true,
onClick: onBack,
});
const handleBack = () => {
triggerHapticLight();
onBack();
};
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
const handleClose = () => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
} else {
onBack();
}
triggerHapticLight();
closeMiniAppOrFallback();
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
@@ -157,10 +139,10 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") {
if (isLoading) {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
role="status"
aria-live="polite"
aria-label={t("loading")}
@@ -195,16 +177,28 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
);
}
if (state === "error") {
if (isAccessDenied) {
return (
<AccessDeniedScreen
serverDetail={requestState.accessDeniedDetail}
primaryAction="back"
onBack={handlePrimaryAction}
openedFromPin={openedFromPin}
/>
);
}
if (isError) {
const handleRetry = () => {
setState("loading");
triggerHapticLight();
setLoading();
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<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">{errorMessage}</p>
<p className="text-error">{requestState.error}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
@@ -229,7 +223,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
if (!duty) {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<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>
@@ -287,7 +281,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
Boolean(duty.username && String(duty.username).trim());
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<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"
@@ -320,13 +314,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
</section>
<div
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
aria-live="polite"
aria-atomic="true"
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">
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
{remainingValueStr}
</span>
<span className="text-xs text-muted-foreground">

View File

@@ -168,7 +168,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
if (!open || !selectedDay) return null;
const panelClassName =
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
"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"
@@ -204,11 +204,14 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
<SheetContent
side="bottom"
className={cn(
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
"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}

View File

@@ -0,0 +1,55 @@
/**
* Shared Mini App screen wrappers for safe-area aware pages.
* Keep route and fallback screens visually consistent and DRY.
*/
"use client";
import { cn } from "@/lib/utils";
import { forwardRef } from "react";
export interface MiniAppScreenProps {
children: React.ReactNode;
className?: string;
}
export interface MiniAppScreenContentProps {
children: React.ReactNode;
className?: string;
}
export interface MiniAppStickyHeaderProps {
children: React.ReactNode;
className?: string;
}
const BASE_SCREEN_CLASS = "content-safe min-h-[var(--tg-viewport-stable-height,100vh)] bg-background";
const BASE_CONTENT_CLASS = "mx-auto flex w-full max-w-[var(--max-width-app)] flex-col";
const BASE_STICKY_HEADER_CLASS = "sticky top-[var(--app-safe-top)] z-10 bg-background";
/**
* Top-level page shell with safe-area and stable viewport height.
*/
export function MiniAppScreen({ children, className }: MiniAppScreenProps) {
return <div className={cn(BASE_SCREEN_CLASS, className)}>{children}</div>;
}
/**
* Inner centered content constrained to app max width.
*/
export function MiniAppScreenContent({ children, className }: MiniAppScreenContentProps) {
return <div className={cn(BASE_CONTENT_CLASS, className)}>{children}</div>;
}
/**
* Sticky top section that respects Telegram safe top inset.
*/
export const MiniAppStickyHeader = forwardRef<HTMLDivElement, MiniAppStickyHeaderProps>(
function MiniAppStickyHeader({ children, className }, ref) {
return (
<div ref={ref} className={cn(BASE_STICKY_HEADER_CLASS, className)}>
{children}
</div>
);
}
);

View File

@@ -1,28 +1,82 @@
"use client";
import { useEffect } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import {
init,
mountMiniAppSync,
mountThemeParamsSync,
bindThemeParamsCssVars,
mountViewport,
bindViewportCssVars,
unmountViewport,
} from "@telegram-apps/sdk-react";
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
import { fixSurfaceContrast, useTelegramTheme } from "@/hooks/use-telegram-theme";
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
import { useAppStore } from "@/store/app-store";
import { getLang } from "@/i18n/messages";
import { useTranslation } from "@/i18n/use-translation";
const EVENT_CONFIG_LOADED = "dt-config-loaded";
export interface TelegramSdkContextValue {
/** True after init() and sync mounts have run; safe to use backButton etc. */
sdkReady: boolean;
}
const TelegramSdkContext = createContext<TelegramSdkContextValue>({
sdkReady: false,
});
export function useTelegramSdkReady(): TelegramSdkContextValue {
return useContext(TelegramSdkContext);
}
/**
* Wraps the app with Telegram Mini App SDK initialization.
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
* and mounts the mini app. Does not call ready() here — the app calls
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
* has finished loading, so Telegram keeps its native loading animation until then.
* mounts the mini app, then mounts viewport and binds viewport CSS vars
* (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.).
* Does not call ready() here — the app calls callMiniAppReadyOnce() from
* lib/telegram-ready when the first visible screen has finished loading.
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
* useTelegramTheme() in the app handles ongoing theme changes.
* Syncs lang from window.__DT_LANG on mount and when config.js fires dt-config-loaded.
*/
/**
* Live theme sync: provider-owned so every route (/, /admin, not-found, error) gets
* data-theme and Mini App chrome color updates when Telegram theme changes.
*/
function ThemeSync() {
useTelegramTheme();
return null;
}
export function TelegramProvider({
children,
}: {
children: React.ReactNode;
}) {
const [sdkReady, setSdkReady] = useState(false);
const setLang = useAppStore((s) => s.setLang);
const lang = useAppStore((s) => s.lang);
const { t } = useTranslation();
// Sync lang from backend config: on mount and when config.js has loaded (all routes, including admin).
useEffect(() => {
if (typeof window === "undefined") return;
setLang(getLang());
const onConfigLoaded = () => setLang(getLang());
window.addEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
return () => window.removeEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
}, [setLang]);
// Apply lang to document (title and html lang) so all routes including admin get correct title.
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = lang;
document.title = t("app.title");
}, [lang, t]);
useEffect(() => {
const cleanup = init({ acceptCustomStyles: true });
@@ -39,8 +93,35 @@ export function TelegramProvider({
mountMiniAppSync();
}
return cleanup;
applyAndroidPerformanceClass();
setSdkReady(true);
let unbindViewportCssVars: (() => void) | undefined;
if (mountViewport.isAvailable()) {
mountViewport()
.then(() => {
if (bindViewportCssVars.isAvailable()) {
unbindViewportCssVars = bindViewportCssVars();
}
})
.catch(() => {
// Viewport not supported (e.g. not in Mini App); ignore.
});
}
return () => {
setSdkReady(false);
unbindViewportCssVars?.();
unmountViewport();
cleanup();
};
}, []);
return <>{children}</>;
return (
<TelegramSdkContext.Provider value={{ sdkReady }}>
<ThemeSync />
{children}
</TelegramSdkContext.Provider>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,74 @@
/**
* Unit tests for AccessDeniedScreen: full-screen access denied, reload and back modes.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { AccessDeniedScreen } from "./AccessDeniedScreen";
import { resetAppStore } from "@/test/test-utils";
describe("AccessDeniedScreen", () => {
beforeEach(() => {
resetAppStore();
});
it("renders translated access denied title and hint", () => {
render(<AccessDeniedScreen primaryAction="reload" />);
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
expect(
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
).toBeInTheDocument();
});
it("shows serverDetail when provided", () => {
render(
<AccessDeniedScreen primaryAction="reload" serverDetail="Custom 403 message" />
);
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
});
it("reload mode shows Reload button", () => {
render(<AccessDeniedScreen primaryAction="reload" />);
const button = screen.getByRole("button", { name: /Reload|Обновить/i });
expect(button).toBeInTheDocument();
const reloadFn = vi.fn();
Object.defineProperty(window, "location", {
value: { ...window.location, reload: reloadFn },
writable: true,
});
fireEvent.click(button);
expect(reloadFn).toHaveBeenCalled();
});
it("back mode shows Back to calendar and calls onBack on click", () => {
const onBack = vi.fn();
render(
<AccessDeniedScreen
primaryAction="back"
onBack={onBack}
openedFromPin={false}
/>
);
const button = screen.getByRole("button", {
name: /Back to calendar|Назад к календарю/i,
});
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onBack).toHaveBeenCalled();
});
it("back mode with openedFromPin shows Close button", () => {
const onBack = vi.fn();
render(
<AccessDeniedScreen
primaryAction="back"
onBack={onBack}
openedFromPin={true}
/>
);
const button = screen.getByRole("button", { name: /Close|Закрыть/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onBack).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,80 @@
/**
* Full-screen access denied view. Used when the user is not allowed (no initData / not localhost)
* or when API returns 403. Matches global-error and not-found layout: no extra chrome, one action.
*/
"use client";
import { useEffect } from "react";
import { getLang, translate } from "@/i18n/messages";
import { useAppStore } from "@/store/app-store";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { Button } from "@/components/ui/button";
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
export interface AccessDeniedScreenProps {
/** Optional detail from API 403 response, shown below the hint. */
serverDetail?: string | null;
/** Primary button: reload (main page) or back/close (deep link). */
primaryAction: "reload" | "back";
/** Called when primaryAction is "back" (e.g. Back to calendar or Close). */
onBack?: () => void;
/** When true and primaryAction is "back", button label is "Close" instead of "Back to calendar". */
openedFromPin?: boolean;
}
/**
* Full-screen access denied: title, hint, optional server detail, single action button.
* Calls setAppContentReady(true) on mount so Telegram receives ready().
*/
export function AccessDeniedScreen({
serverDetail,
primaryAction,
onBack,
openedFromPin = false,
}: AccessDeniedScreenProps) {
const lang = getLang();
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
useEffect(() => {
setAppContentReady(true);
}, [setAppContentReady]);
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
const handleClick = () => {
triggerHapticLight();
if (primaryAction === "reload") {
if (typeof window !== "undefined") {
window.location.reload();
}
} else {
onBack?.();
}
};
const buttonLabel =
primaryAction === "reload"
? translate(lang, "access_denied.reload")
: openedFromPin
? translate(lang, "current_duty.close")
: translate(lang, "current_duty.back");
return (
<FullScreenStateShell
title={translate(lang, "access_denied")}
description={translate(lang, "access_denied.hint")}
primaryAction={
<Button type="button" onClick={handleClick}>
{buttonLabel}
</Button>
}
>
{hasDetail && (
<p className="text-center text-sm text-muted-foreground">
{serverDetail}
</p>
)}
</FullScreenStateShell>
);
}

View File

@@ -8,6 +8,7 @@
import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { triggerHapticLight } from "@/lib/telegram-haptic";
export interface ErrorStateProps {
/** Error message to display. If not provided, uses generic i18n message. */
@@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
variant="default"
size="sm"
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
onClick={onRetry}
onClick={() => {
triggerHapticLight();
onRetry();
}}
>
{t("error.retry")}
</Button>

View File

@@ -0,0 +1,51 @@
/**
* Shared full-screen state layout for fallback screens (access denied, not-found, error).
* Uses content-safe, app tokens, and consistent spacing so all fallback screens look like the same app.
*/
"use client";
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
export interface FullScreenStateShellProps {
/** Main heading (e.g. "Access denied", "Page not found"). */
title: React.ReactNode;
/** Optional description or message below the title. */
description?: React.ReactNode;
/** Optional extra content (e.g. server detail, secondary text). */
children?: React.ReactNode;
/** Primary action (Button or Link). */
primaryAction: React.ReactNode;
/** Wrapper role. Default "alert" for error/denied states. */
role?: "alert" | "status";
/** Optional extra class names for the wrapper. */
className?: string;
}
/**
* Full-screen centered shell with title, optional description, and primary action.
* Use for access denied, not-found, and in-app error boundary screens.
*/
export function FullScreenStateShell({
title,
description,
children,
primaryAction,
role = "alert",
className,
}: FullScreenStateShellProps) {
return (
<MiniAppScreen className={className}>
<MiniAppScreenContent className="items-center justify-center gap-4 px-3 text-foreground">
<div role={role} className="flex flex-col items-center gap-4">
<h1 className="text-xl font-semibold">{title}</h1>
{description != null && (
<p className="text-center text-muted-foreground">{description}</p>
)}
{children}
{primaryAction}
</div>
</MiniAppScreenContent>
</MiniAppScreen>
);
}

View File

@@ -4,4 +4,4 @@
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { AccessDenied } from "./AccessDenied";
export { AccessDeniedScreen } from "./AccessDeniedScreen";

View File

@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-4 sm:px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
@@ -65,7 +65,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
className={cn("px-4 sm:px-6", className)}
{...props}
/>
)
@@ -75,7 +75,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
className={cn("flex items-center px-4 sm:px-6 [.border-t]:pt-6", className)}
{...props}
/>
)

View File

@@ -53,12 +53,18 @@ function SheetContent({
showCloseButton = true,
onCloseAnimationEnd,
onAnimationEnd,
overlayClassName,
closeLabel = "Close",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
onCloseAnimationEnd?: () => void
/** Optional class name applied to the overlay (e.g. backdrop-blur-md). */
overlayClassName?: string
/** Accessible label for the close button text (sr-only). */
closeLabel?: string
}) {
const useForceMount = Boolean(onCloseAnimationEnd)
@@ -74,7 +80,10 @@ function SheetContent({
return (
<SheetPortal>
<SheetOverlay forceMount={useForceMount ? true : undefined} />
<SheetOverlay
forceMount={useForceMount ? true : undefined}
className={overlayClassName}
/>
<SheetPrimitive.Content
data-slot="sheet-content"
forceMount={useForceMount ? true : undefined}
@@ -88,7 +97,7 @@ function SheetContent({
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
"inset-x-0 bottom-0 h-auto border-t pb-[calc(24px+var(--tg-viewport-content-safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
@@ -97,7 +106,7 @@ function SheetContent({
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
<span className="sr-only">{closeLabel}</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>

View File

@@ -42,7 +42,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[var(--shadow-card)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}

View File

@@ -0,0 +1,4 @@
export * from "./use-telegram-back-button";
export * from "./use-telegram-settings-button";
export * from "./use-telegram-close-action";
export * from "./use-telegram-interaction-policy";

View File

@@ -0,0 +1,44 @@
/**
* Telegram BackButton adapter.
* Keeps SDK calls out of feature components.
*/
"use client";
import { useEffect } from "react";
import { backButton } from "@telegram-apps/sdk-react";
export interface UseTelegramBackButtonOptions {
enabled: boolean;
onClick: () => void;
}
export function useTelegramBackButton({ enabled, onClick }: UseTelegramBackButtonOptions) {
useEffect(() => {
if (!enabled) return;
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onClick);
}
} catch {
// Non-Telegram environment; ignore.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [enabled, onClick]);
}

View File

@@ -0,0 +1,19 @@
/**
* Telegram close adapter.
* Provides one place to prefer Mini App close and fallback safely.
*/
"use client";
import { useCallback } from "react";
import { closeMiniApp } from "@telegram-apps/sdk-react";
export function useTelegramCloseAction(onFallback: () => void) {
return useCallback(() => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
return;
}
onFallback();
}, [onFallback]);
}

View File

@@ -0,0 +1,80 @@
/**
* Telegram interaction policy hooks.
* Policy defaults: keep vertical swipes enabled; enable closing confirmation only
* for stateful flows where user input can be lost.
*/
"use client";
import { useEffect } from "react";
function getTelegramWebApp(): {
enableVerticalSwipes?: () => void;
disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
} | null {
if (typeof window === "undefined") return null;
return (window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram
?.WebApp as {
enableVerticalSwipes?: () => void;
disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
} | null;
}
/**
* Keep Telegram vertical swipes enabled by default.
* Disable only for screens with conflicting in-app gestures.
*/
export function useTelegramVerticalSwipePolicy(disableVerticalSwipes: boolean) {
useEffect(() => {
const webApp = getTelegramWebApp();
if (!webApp) return;
try {
if (disableVerticalSwipes) {
webApp.disableVerticalSwipes?.();
} else {
webApp.enableVerticalSwipes?.();
}
} catch {
// Ignore unsupported clients.
}
return () => {
if (!disableVerticalSwipes) return;
try {
webApp.enableVerticalSwipes?.();
} catch {
// Ignore unsupported clients.
}
};
}, [disableVerticalSwipes]);
}
/**
* Enable confirmation before closing Mini App for stateful flows.
*/
export function useTelegramClosingConfirmation(enabled: boolean) {
useEffect(() => {
const webApp = getTelegramWebApp();
if (!webApp) return;
try {
if (enabled) {
webApp.enableClosingConfirmation?.();
} else {
webApp.disableClosingConfirmation?.();
}
} catch {
// Ignore unsupported clients.
}
return () => {
if (!enabled) return;
try {
webApp.disableClosingConfirmation?.();
} catch {
// Ignore unsupported clients.
}
};
}, [enabled]);
}

View File

@@ -0,0 +1,44 @@
/**
* Telegram SettingsButton adapter.
* Keeps SDK calls out of feature components.
*/
"use client";
import { useEffect } from "react";
import { settingsButton } from "@telegram-apps/sdk-react";
export interface UseTelegramSettingsButtonOptions {
enabled: boolean;
onClick: () => void;
}
export function useTelegramSettingsButton({ enabled, onClick }: UseTelegramSettingsButtonOptions) {
useEffect(() => {
if (!enabled) return;
let offClick: (() => void) | undefined;
try {
if (settingsButton.mount.isAvailable()) {
settingsButton.mount();
}
if (settingsButton.show.isAvailable()) {
settingsButton.show();
}
if (settingsButton.onClick.isAvailable()) {
offClick = settingsButton.onClick(onClick);
}
} catch {
// Non-Telegram environment; ignore.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (settingsButton.hide.isAvailable()) {
settingsButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [enabled, onClick]);
}

View File

@@ -1,14 +1,12 @@
/**
* Application initialization: language sync, access-denied logic, deep link routing.
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
* Application initialization: access-denied logic and deep link routing.
* Document lang/title are owned by TelegramProvider (all routes).
*/
"use client";
import { useEffect } from "react";
import { useAppStore } from "@/store/app-store";
import { getLang } from "@/i18n/messages";
import { useTranslation } from "@/i18n/use-translation";
import { RETRY_DELAY_MS } from "@/lib/constants";
export interface UseAppInitParams {
@@ -19,29 +17,12 @@ export interface UseAppInitParams {
}
/**
* Syncs language from backend config, applies document lang/title, handles access denied
* when not allowed, and routes to current duty view when opened via startParam=duty.
* Handles access denied when not allowed and routes to current duty view when opened via startParam=duty.
*/
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
const setLang = useAppStore((s) => s.setLang);
const lang = useAppStore((s) => s.lang);
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
const setLoading = useAppStore((s) => s.setLoading);
const setCurrentView = useAppStore((s) => s.setCurrentView);
const { t } = useTranslation();
// Sync lang from backend config (window.__DT_LANG).
useEffect(() => {
if (typeof window === "undefined") return;
setLang(getLang());
}, [setLang]);
// Apply lang to document (title and html lang) for accessibility and i18n.
useEffect(() => {
if (typeof document === "undefined") return;
document.documentElement.lang = lang;
document.title = t("app.title");
}, [lang, t]);
// When not allowed (no initData and not localhost), show access denied after delay.
useEffect(() => {

View File

@@ -0,0 +1,42 @@
/**
* Unit tests for useAutoRefresh: no immediate refresh (avoids duplicate first fetch).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { useAutoRefresh } from "./use-auto-refresh";
describe("useAutoRefresh", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("does not call refresh immediately when isCurrentMonth is true", () => {
const refresh = vi.fn();
renderHook(() => useAutoRefresh(refresh, true));
expect(refresh).not.toHaveBeenCalled();
});
it("calls refresh on interval when isCurrentMonth is true", () => {
const refresh = vi.fn();
renderHook(() => useAutoRefresh(refresh, true));
vi.advanceTimersByTime(60_000);
expect(refresh).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60_000);
expect(refresh).toHaveBeenCalledTimes(2);
});
it("does not call refresh when isCurrentMonth is false", () => {
const refresh = vi.fn();
renderHook(() => useAutoRefresh(refresh, false));
vi.advanceTimersByTime(120_000);
expect(refresh).not.toHaveBeenCalled();
});
});

View File

@@ -10,7 +10,8 @@ import { useEffect, useRef } from "react";
const AUTO_REFRESH_INTERVAL_MS = 60000;
/**
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
* When isCurrentMonth is true, starts a 60-second interval to refresh. Does not call refresh()
* immediately so the initial load is handled only by useMonthData (avoids duplicate first fetch).
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
*/
export function useAutoRefresh(
@@ -22,7 +23,6 @@ export function useAutoRefresh(
useEffect(() => {
if (!isCurrentMonth) return;
refreshRef.current();
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [isCurrentMonth]);

View File

@@ -0,0 +1,56 @@
/**
* Shared request-state model for async flows.
*/
"use client";
import { useCallback, useMemo, useState } from "react";
export type RequestPhase = "idle" | "loading" | "success" | "error" | "accessDenied";
export interface RequestState {
phase: RequestPhase;
error: string | null;
accessDeniedDetail: string | null;
}
export function useRequestState(initialPhase: RequestPhase = "idle") {
const [state, setState] = useState<RequestState>({
phase: initialPhase,
error: null,
accessDeniedDetail: null,
});
const setLoading = useCallback(() => {
setState({ phase: "loading", error: null, accessDeniedDetail: null });
}, []);
const setSuccess = useCallback(() => {
setState({ phase: "success", error: null, accessDeniedDetail: null });
}, []);
const setError = useCallback((error: string) => {
setState({ phase: "error", error, accessDeniedDetail: null });
}, []);
const setAccessDenied = useCallback((detail: string | null = null) => {
setState({ phase: "accessDenied", error: null, accessDeniedDetail: detail });
}, []);
const reset = useCallback((phase: RequestPhase = "idle") => {
setState({ phase, error: null, accessDeniedDetail: null });
}, []);
const flags = useMemo(
() => ({
isIdle: state.phase === "idle",
isLoading: state.phase === "loading",
isSuccess: state.phase === "success",
isError: state.phase === "error",
isAccessDenied: state.phase === "accessDenied",
}),
[state.phase]
);
return { state, setLoading, setSuccess, setError, setAccessDenied, reset, ...flags };
}

View File

@@ -0,0 +1,20 @@
/**
* Unified screen-readiness signal for ReadyGate.
* Marks app content as ready once when condition becomes true.
*/
"use client";
import { useEffect, useRef } from "react";
import { useAppStore } from "@/store/app-store";
export function useScreenReady(ready: boolean) {
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
const markedRef = useRef(false);
useEffect(() => {
if (!ready || markedRef.current) return;
markedRef.current = true;
setAppContentReady(true);
}, [ready, setAppContentReady]);
}

View File

@@ -1,6 +1,6 @@
/**
* Touch swipe detection for horizontal month navigation.
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
* Tracks move/cancel so diagonal or vertical scroll does not trigger month change.
*/
"use client";
@@ -15,8 +15,9 @@ export interface UseSwipeOptions {
}
/**
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
* Attaches touchstart/touchmove/touchend to the element ref. Fires onSwipeLeft or onSwipeRight
* only when horizontal movement exceeds threshold and dominates over vertical (no cancel during move).
* Use touch-action: pan-y on the swipe area so vertical scroll is preserved and horizontal intent is clearer.
*/
export function useSwipe(
elementRef: React.RefObject<HTMLElement | null>,
@@ -27,6 +28,7 @@ export function useSwipe(
const { threshold = 50, disabled = false } = options;
const startX = useRef(0);
const startY = useRef(0);
const cancelledRef = useRef(false);
const onSwipeLeftRef = useRef(onSwipeLeft);
const onSwipeRightRef = useRef(onSwipeRight);
onSwipeLeftRef.current = onSwipeLeft;
@@ -41,10 +43,21 @@ export function useSwipe(
const t = e.changedTouches[0];
startX.current = t.clientX;
startY.current = t.clientY;
cancelledRef.current = false;
};
const handleMove = (e: TouchEvent) => {
if (e.changedTouches.length === 0 || cancelledRef.current) return;
const t = e.changedTouches[0];
const deltaX = Math.abs(t.clientX - startX.current);
const deltaY = Math.abs(t.clientY - startY.current);
if (deltaY > deltaX) {
cancelledRef.current = true;
}
};
const handleEnd = (e: TouchEvent) => {
if (e.changedTouches.length === 0) return;
if (e.changedTouches.length === 0 || cancelledRef.current) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX.current;
const deltaY = t.clientY - startY.current;
@@ -58,9 +71,11 @@ export function useSwipe(
};
el.addEventListener("touchstart", handleStart, { passive: true });
el.addEventListener("touchmove", handleMove, { passive: true });
el.addEventListener("touchend", handleEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleStart);
el.removeEventListener("touchmove", handleMove);
el.removeEventListener("touchend", handleEnd);
};
}, [elementRef, disabled, threshold]);

View File

@@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({
isThemeParamsDark: vi.fn(),
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) },
}));
describe("getFallbackScheme", () => {

View File

@@ -6,6 +6,7 @@ import {
isThemeParamsDark,
setMiniAppBackgroundColor,
setMiniAppHeaderColor,
setMiniAppBottomBarColor,
} from "@telegram-apps/sdk-react";
/**
@@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void {
if (setMiniAppHeaderColor.isAvailable()) {
setMiniAppHeaderColor("bg_color");
}
if (setMiniAppBottomBarColor.isAvailable()) {
setMiniAppBottomBarColor("bottom_bar_bg_color");
}
}
/**

View File

@@ -38,6 +38,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"aria.duty": "On duty",
"aria.unavailable": "Unavailable",
"aria.vacation": "Vacation",
"aria.calendar": "Calendar",
"aria.day_info": "Day info",
"event_type.duty": "Duty",
"event_type.unavailable": "Unavailable",
@@ -81,6 +82,28 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"not_found.description": "The page you are looking for does not exist.",
"not_found.open_calendar": "Open calendar",
"access_denied.hint": "Open the app again from Telegram.",
"access_denied.reload": "Reload",
"admin.link": "Admin",
"admin.title": "Admin",
"admin.reassign_duty": "Reassign duty",
"admin.select_user": "Select user",
"admin.current_assignee": "Current",
"admin.reassign_success": "Duty reassigned",
"admin.access_denied": "Access only for administrators.",
"admin.duty_not_found": "Duty not found",
"admin.user_not_found": "User not found",
"admin.back_to_calendar": "Back to calendar",
"admin.loading_users": "Loading users…",
"admin.no_duties": "No duties this month.",
"admin.no_users_for_assign": "No users available for assignment.",
"admin.save": "Save",
"admin.list_aria": "List of duties to reassign",
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
"admin.section_aria": "Duties for {date}",
"admin.reassign_error_generic": "Could not reassign duty. Try again.",
"admin.reassign_error_denied": "Access denied.",
"admin.reassign_error_not_found": "Duty or user not found.",
"admin.reassign_error_network": "Network error. Check your connection.",
},
ru: {
"app.title": "Календарь дежурств",
@@ -114,6 +137,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"aria.duty": "Дежурные",
"aria.unavailable": "Недоступен",
"aria.vacation": "Отпуск",
"aria.calendar": "Календарь",
"aria.day_info": "Информация о дне",
"event_type.duty": "Дежурство",
"event_type.unavailable": "Недоступен",
@@ -157,6 +181,28 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"not_found.description": "Запрашиваемая страница не существует.",
"not_found.open_calendar": "Открыть календарь",
"access_denied.hint": "Откройте приложение снова из Telegram.",
"access_denied.reload": "Обновить",
"admin.link": "Админка",
"admin.title": "Админка",
"admin.reassign_duty": "Переназначить дежурного",
"admin.select_user": "Выберите пользователя",
"admin.current_assignee": "Текущий",
"admin.reassign_success": "Дежурство переназначено",
"admin.access_denied": "Доступ только для администраторов.",
"admin.duty_not_found": "Дежурство не найдено",
"admin.user_not_found": "Пользователь не найден",
"admin.back_to_calendar": "Назад к календарю",
"admin.loading_users": "Загрузка пользователей…",
"admin.no_duties": "В этом месяце дежурств нет.",
"admin.no_users_for_assign": "Нет пользователей для назначения.",
"admin.save": "Сохранить",
"admin.list_aria": "Список дежурств для перераспределения",
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
"admin.section_aria": "Дежурства за {date}",
"admin.reassign_error_generic": "Не удалось переназначить дежурство. Попробуйте снова.",
"admin.reassign_error_denied": "Доступ запрещён.",
"admin.reassign_error_not_found": "Дежурство или пользователь не найдены.",
"admin.reassign_error_network": "Ошибка сети. Проверьте подключение.",
},
};

View File

@@ -4,7 +4,14 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
import {
fetchDuties,
fetchCalendarEvents,
fetchAdminMe,
fetchAdminUsers,
patchAdminDuty,
AccessDeniedError,
} from "./api";
describe("fetchDuties", () => {
const originalFetch = globalThis.fetch;
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("fetchAdminMe", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns is_admin true on 200", async () => {
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: true });
});
it("returns is_admin false on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
it("returns is_admin false on non-200 and non-403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
it("returns is_admin false when response is not boolean", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: "yes" }),
} as Response);
const result = await fetchAdminMe("init-data", "en");
expect(result).toEqual({ is_admin: false });
});
});
describe("fetchAdminUsers", () => {
const originalFetch = globalThis.fetch;
const validUsers = [
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
];
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(validUsers),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns users array on 200", async () => {
const result = await fetchAdminUsers("init-data", "en");
expect(result).toEqual(validUsers);
});
it("throws AccessDeniedError on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
await expect(fetchAdminUsers("init-data", "en")).rejects.toThrow(AccessDeniedError);
await expect(fetchAdminUsers("init-data", "en")).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Admin only",
});
});
it("filters invalid items from response", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve([
validUsers[0],
{ id: 2, full_name: "Bob", username: 123, role_id: 2 },
{ id: 3 },
]),
} as Response);
const result = await fetchAdminUsers("init-data", "en");
expect(result).toEqual([validUsers[0]]);
});
});
describe("patchAdminDuty", () => {
const originalFetch = globalThis.fetch;
const updatedDuty = {
id: 5,
user_id: 2,
start_at: "2025-03-01T09:00:00Z",
end_at: "2025-03-01T18:00:00Z",
};
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve(updatedDuty),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("sends PATCH with user_id and returns updated duty", async () => {
const result = await patchAdminDuty(5, 2, "init-data", "en");
expect(result).toEqual(updatedDuty);
const fetchCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[1]?.method).toBe("PATCH");
expect(fetchCall[1]?.body).toBe(JSON.stringify({ user_id: 2 }));
});
it("throws AccessDeniedError on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
await expect(
patchAdminDuty(5, 2, "init-data", "en")
).rejects.toThrow(AccessDeniedError);
});
it("throws with server detail on 400", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ detail: "User not found" }),
} as Response);
await expect(
patchAdminDuty(5, 999, "init-data", "en")
).rejects.toThrow("User not found");
});
});

View File

@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
type ApiLang = "ru" | "en";
/** User summary for admin dropdown (GET /api/admin/users). */
export interface UserForAdmin {
id: number;
full_name: string;
username: string | null;
role_id: number | null;
}
/** Minimal duty shape returned by PATCH /api/admin/duties/:id. */
export interface DutyReassignResponse {
id: number;
user_id: number;
start_at: string;
end_at: string;
}
/** Minimal runtime check for a single duty item (required fields). */
function isDutyWithUser(x: unknown): x is DutyWithUser {
if (!x || typeof x !== "object") return false;
@@ -112,6 +128,27 @@ function buildFetchOptions(
return { headers, signal: controller.signal, cleanup };
}
/**
* Parse 403 response body for user-facing detail. Returns default i18n message if body is invalid.
*/
async function handle403Response(
res: Response,
acceptLang: ApiLang,
defaultI18nKey: string
): Promise<string> {
let detail = translate(acceptLang, defaultI18nKey);
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail = typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
return detail;
}
/**
* Fetch duties for date range. Throws AccessDeniedError on 403.
* Rethrows AbortError when the request is cancelled (e.g. stale load).
@@ -132,17 +169,7 @@ export async function fetchDuties(
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
logger.warn("Access denied", from, to);
let detail = translate(acceptLang, "access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
const detail = await handle403Response(res, acceptLang, "access_denied");
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) {
@@ -181,17 +208,7 @@ export async function fetchCalendarEvents(
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
logger.warn("Access denied", from, to, "calendar-events");
let detail = translate(acceptLang, "access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
const detail = await handle403Response(res, acceptLang, "access_denied");
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) return [];
@@ -207,3 +224,133 @@ export async function fetchCalendarEvents(
opts.cleanup();
}
}
/**
* Fetch admin status for the current user (GET /api/admin/me).
* Returns { is_admin: true } or { is_admin: false }. On 403, treat as not admin.
*/
export async function fetchAdminMe(
initData: string,
acceptLang: ApiLang
): Promise<{ is_admin: boolean }> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/me`;
const opts = buildFetchOptions(initData, acceptLang);
try {
logger.debug("API request", "/api/admin/me");
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
return { is_admin: false };
}
if (!res.ok) {
return { is_admin: false };
}
const data = (await res.json()) as { is_admin?: boolean };
return {
is_admin: typeof data?.is_admin === "boolean" ? data.is_admin : false,
};
} catch (e) {
logger.warn("API request failed", "/api/admin/me", e);
return { is_admin: false };
} finally {
opts.cleanup();
}
}
/**
* Fetch users for admin dropdown (GET /api/admin/users). Admin only; throws AccessDeniedError on 403.
*/
export async function fetchAdminUsers(
initData: string,
acceptLang: ApiLang,
signal?: AbortSignal | null
): Promise<UserForAdmin[]> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/users`;
const opts = buildFetchOptions(initData, acceptLang, signal);
try {
logger.debug("API request", "/api/admin/users");
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) {
throw new Error(translate(acceptLang, "error_load_failed"));
}
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.filter(
(x: unknown): x is UserForAdmin =>
x != null &&
typeof (x as UserForAdmin).id === "number" &&
typeof (x as UserForAdmin).full_name === "string" &&
((x as UserForAdmin).username === null ||
typeof (x as UserForAdmin).username === "string") &&
((x as UserForAdmin).role_id === null ||
typeof (x as UserForAdmin).role_id === "number")
);
} catch (e) {
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
throw e;
}
logger.error("API request failed", "/api/admin/users", e);
throw e;
} finally {
opts.cleanup();
}
}
/**
* Reassign a duty to another user (PATCH /api/admin/duties/:id). Admin only.
* Returns updated duty (id, user_id, start_at, end_at). Throws on 403/404/400.
*/
export async function patchAdminDuty(
dutyId: number,
userId: number,
initData: string,
acceptLang: ApiLang
): Promise<DutyReassignResponse> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/admin/duties/${dutyId}`;
const opts = buildFetchOptions(initData, acceptLang);
try {
logger.debug("API request", "PATCH", url, { user_id: userId });
const res = await fetch(url, {
method: "PATCH",
headers: {
...opts.headers,
"Content-Type": "application/json",
},
body: JSON.stringify({ user_id: userId }),
signal: opts.signal,
});
if (res.status === 403) {
const detail = await handle403Response(res, acceptLang, "admin.access_denied");
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg =
data && typeof (data as { detail?: string }).detail === "string"
? (data as { detail: string }).detail
: translate(acceptLang, "error_generic");
throw new Error(msg);
}
const out = data as DutyReassignResponse;
if (
typeof out?.id !== "number" ||
typeof out?.user_id !== "number" ||
typeof out?.start_at !== "string" ||
typeof out?.end_at !== "string"
) {
throw new Error(translate(acceptLang, "error_load_failed"));
}
return out;
} finally {
opts.cleanup();
}
}

View File

@@ -0,0 +1,51 @@
/**
* Unit tests for openPhoneLink: creates a temporary tel: link and triggers click.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { openPhoneLink } from "./open-phone-link";
describe("openPhoneLink", () => {
let appendedAnchor: HTMLAnchorElement | null;
let anchorClickSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
appendedAnchor = null;
anchorClickSpy = vi.fn();
vi.spyOn(window, "open").mockReturnValue(null);
vi.spyOn(document.body, "appendChild").mockImplementation((node: Node) => {
const el = node as HTMLAnchorElement;
if (el.tagName === "A" && el.href) {
appendedAnchor = el;
el.click = anchorClickSpy;
}
return node;
});
vi.spyOn(document.body, "removeChild").mockImplementation((node: Node) => node);
});
it("does nothing when phone is null or empty", () => {
openPhoneLink(null);
openPhoneLink("");
openPhoneLink(" ");
expect(appendedAnchor).toBeNull();
expect(anchorClickSpy).not.toHaveBeenCalled();
});
it("creates anchor with tel URL and triggers click for valid phone", () => {
openPhoneLink("+79991234567");
expect(appendedAnchor).not.toBeNull();
expect(appendedAnchor!.href).toMatch(/tel:\+79991234567$/);
expect(anchorClickSpy).toHaveBeenCalled();
});
it("builds correct tel URL for 10-digit Russian number", () => {
openPhoneLink("9146522209");
expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/);
});
it("builds correct tel URL for 11-digit number starting with 8", () => {
openPhoneLink("89146522209");
expect(appendedAnchor!.href).toMatch(/tel:\+79146522209$/);
});
});

View File

@@ -0,0 +1,46 @@
/**
* Opens a phone number for calling. Uses native tel: navigation so the OS
* or WebView can open the dialer. Does not use Telegram openLink() for tel:
* (protocol is not supported; it closes the app on desktop and does nothing on mobile).
*/
/**
* Builds a tel: URL from a phone string (digits and optional leading + only).
*/
function buildTelUrl(phone: string): string {
const trimmed = String(phone).trim();
if (trimmed === "") return "";
const digits = trimmed.replace(/\D/g, "");
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
return `tel:+7${digits.slice(1)}`;
}
if (digits.length === 10) {
return `tel:+7${digits}`;
}
return `tel:${trimmed.startsWith("+") ? "+" : ""}${digits}`;
}
/**
* Opens the given phone number for calling. Tries window.open(telUrl) first
* (reported to work for tel: in some Telegram WebViews), then a programmatic
* click on a temporary tel: link. openLink(tel:) is not used — Telegram does
* not support it (closes app on desktop, no dialer on mobile). Safe to call
* from click handlers; does not throw.
*/
export function openPhoneLink(phone: string | null | undefined): void {
if (phone == null || String(phone).trim() === "") return;
const telUrl = buildTelUrl(phone);
if (telUrl === "") return;
if (typeof window === "undefined" || !window.document) return;
const opened = window.open(telUrl);
if (opened !== null) return;
const anchor = window.document.createElement("a");
anchor.href = telUrl;
anchor.style.display = "none";
anchor.setAttribute("aria-hidden", "true");
window.document.body.appendChild(anchor);
anchor.click();
anchor.remove();
}

View File

@@ -0,0 +1,26 @@
/**
* Detects Android Telegram Mini App performance class from User-Agent and sets
* data-perf on document.documentElement so CSS can reduce animations on low-end devices.
* @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent
*/
import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react";
const DATA_ATTR = "data-perf";
/**
* Runs once: if running in Telegram on Android with LOW performance class,
* sets data-perf="low" on the document root for CSS to minimize animations.
*/
export function applyAndroidPerformanceClass(): void {
if (typeof document === "undefined") return;
try {
const data = retrieveAndroidDeviceData();
const perf = data?.performanceClass;
if (perf === "LOW") {
document.documentElement.setAttribute(DATA_ATTR, "low");
}
} catch {
// Not in Telegram or not Android; ignore.
}
}

View File

@@ -0,0 +1,19 @@
/**
* Triggers Telegram Mini App haptic feedback when available.
* Use on primary actions and key interactions to mimic native Telegram behavior.
*/
import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react";
/**
* Triggers light impact haptic feedback. No-op when not in Telegram or unsupported.
*/
export function triggerHapticLight(): void {
try {
if (hapticFeedbackImpactOccurred.isAvailable()) {
hapticFeedbackImpactOccurred("light");
}
} catch {
// SDK not available; ignore.
}
}

View File

@@ -0,0 +1,16 @@
/**
* Telegram interaction policy for Mini App behavior.
* Keep this as a single source of truth for platform UX decisions.
*/
/**
* Keep vertical swipes enabled unless a specific screen has a hard conflict
* with Telegram swipe-to-minimize behavior.
*/
export const DISABLE_VERTICAL_SWIPES_BY_DEFAULT = false;
/**
* Show closing confirmation only for stateful flows where user choices can be
* lost by accidental close/minimize.
*/
export const ENABLE_CLOSING_CONFIRMATION_FOR_STATEFUL_ADMIN_FLOW = true;

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for openTelegramProfile: Mini Appfriendly Telegram link opening.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const openTelegramLinkMock = vi.fn();
const isAvailableFn = vi.fn().mockReturnValue(true);
vi.mock("@telegram-apps/sdk-react", () => ({
openTelegramLink: Object.assign(openTelegramLinkMock, {
isAvailable: () => isAvailableFn(),
}),
}));
describe("openTelegramProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
isAvailableFn.mockReturnValue(true);
});
it("calls openTelegramLink with t.me URL when SDK is available", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("alice");
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/alice");
});
it("strips leading @ from username", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("@bob");
expect(openTelegramLinkMock).toHaveBeenCalledWith("https://t.me/bob");
});
it("does nothing when username is empty", async () => {
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("");
openTelegramProfile(null);
openTelegramProfile(undefined);
expect(openTelegramLinkMock).not.toHaveBeenCalled();
});
it("falls back to window.open when SDK is unavailable", async () => {
isAvailableFn.mockReturnValue(false);
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
vi.resetModules();
const { openTelegramProfile } = await import("./telegram-link");
openTelegramProfile("alice");
expect(openSpy).toHaveBeenCalledWith(
"https://t.me/alice",
"_blank",
"noopener,noreferrer"
);
openSpy.mockRestore();
});
});

View File

@@ -0,0 +1,28 @@
/**
* Opens a Telegram profile (t.me/username) in a Mini Appfriendly way.
* Uses SDK openTelegramLink() when available so the link opens inside Telegram;
* otherwise falls back to window.open.
*/
import { openTelegramLink } from "@telegram-apps/sdk-react";
/**
* Opens the given Telegram username profile. When running inside the Mini App,
* uses openTelegramLink() so the link opens in-app; otherwise opens in a new tab.
* Safe to call from click handlers; does not throw.
*/
export function openTelegramProfile(username: string | null | undefined): void {
const clean = username ? String(username).trim().replace(/^@+/, "") : "";
if (!clean) return;
if (typeof window === "undefined") return;
const url = `https://t.me/${encodeURIComponent(clean)}`;
try {
if (openTelegramLink.isAvailable()) {
openTelegramLink(url);
return;
}
} catch {
// SDK not available or not in Mini App context.
}
window.open(url, "_blank", "noopener,noreferrer");
}

View File

@@ -1,15 +1,17 @@
/**
* Single-call wrapper for Telegram Mini App ready().
* Single-call wrapper for Telegram Mini App ready() and expand().
* Called once when the first visible screen has finished loading so Telegram
* hides its native loading animation only after our content is ready.
* Also expands the Mini App to full height when supported.
*/
import { miniAppReady } from "@telegram-apps/sdk-react";
import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react";
let readyCalled = false;
/**
* Calls Telegram miniAppReady() at most once per session.
* Calls Telegram miniAppReady() at most once per session, then expandViewport()
* when available so the app opens to full height.
* Safe when SDK is unavailable (e.g. non-Telegram environment).
*/
export function callMiniAppReadyOnce(): void {
@@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void {
miniAppReady();
readyCalled = true;
}
if (expandViewport.isAvailable()) {
expandViewport();
}
} catch {
// SDK not available or not in Mini App context; no-op.
}

View File

@@ -0,0 +1,7 @@
/**
* Inline script for first-paint theme: hash (tgWebAppColorScheme + themeParams),
* then Telegram WebApp, then CSS --tg-color-scheme, then prefers-color-scheme.
* Sets data-theme and Mini App bg/header colors. Shared by layout and global-error.
*/
export const THEME_BOOTSTRAP_SCRIPT =
"(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();";

View File

@@ -4,92 +4,38 @@
*/
import { create } from "zustand";
import type { DutyWithUser, CalendarEvent } from "@/types";
import { getStartParamFromUrl } from "@/lib/launch-params";
import type { CalendarSlice } from "@/store/slices/calendar-slice";
import { createCalendarSlice } from "@/store/slices/calendar-slice";
import type { SessionSlice } from "@/store/slices/session-slice";
import { createSessionSlice } from "@/store/slices/session-slice";
import type { ViewSlice } from "@/store/slices/view-slice";
import { createViewSlice } from "@/store/slices/view-slice";
export type CurrentView = "calendar" | "currentDuty";
/** YYYY-MM key for the month that duties/calendarEvents belong to; null when none loaded. */
export type DataForMonthKey = string | null;
export interface AppState {
type AppStatePatch = Partial<{
currentMonth: Date;
/** When set, we are loading this month; currentMonth and data stay until load completes. */
pendingMonth: Date | null;
lang: "ru" | "en";
duties: DutyWithUser[];
calendarEvents: CalendarEvent[];
/** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */
dataForMonthKey: DataForMonthKey;
duties: CalendarSlice["duties"];
calendarEvents: CalendarSlice["calendarEvents"];
dataForMonthKey: CalendarSlice["dataForMonthKey"];
loading: boolean;
error: string | null;
accessDenied: boolean;
/** Server detail from API 403 response; shown in AccessDenied component. */
accessDeniedDetail: string | null;
currentView: CurrentView;
currentView: ViewSlice["currentView"];
selectedDay: string | null;
/** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean;
isAdmin: boolean;
}>;
setCurrentMonth: (d: Date) => void;
nextMonth: () => void;
prevMonth: () => void;
setDuties: (d: DutyWithUser[]) => void;
setCalendarEvents: (e: CalendarEvent[]) => void;
setLoading: (v: boolean) => void;
setError: (msg: string | null) => void;
setAccessDenied: (v: boolean) => void;
setAccessDeniedDetail: (v: string | null) => void;
setLang: (v: "ru" | "en") => void;
setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void;
setAppContentReady: (v: boolean) => void;
export interface AppState extends SessionSlice, CalendarSlice, ViewSlice {
/** Batch multiple state updates into a single re-render. */
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady">>) => void;
}
const now = new Date();
const initialMonth = new Date(now.getFullYear(), now.getMonth(), 1);
/** Initial view: currentDuty when opened via deep link (startParam=duty), else calendar. */
function getInitialView(): CurrentView {
if (typeof window === "undefined") return "calendar";
return getStartParamFromUrl() === "duty" ? "currentDuty" : "calendar";
batchUpdate: (partial: AppStatePatch) => void;
}
export const useAppStore = create<AppState>((set) => ({
currentMonth: initialMonth,
pendingMonth: null,
lang: "en",
duties: [],
calendarEvents: [],
dataForMonthKey: null,
loading: false,
error: null,
accessDenied: false,
accessDeniedDetail: null,
currentView: getInitialView(),
selectedDay: null,
appContentReady: false,
setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1),
})),
prevMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1),
})),
setDuties: (d) => set({ duties: d }),
setCalendarEvents: (e) => set({ calendarEvents: e }),
setLoading: (v) => set({ loading: v }),
setError: (msg) => set({ error: msg }),
setAccessDenied: (v) => set({ accessDenied: v }),
setAccessDeniedDetail: (v) => set({ accessDeniedDetail: v }),
setLang: (v) => set({ lang: v }),
setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }),
setAppContentReady: (v) => set({ appContentReady: v }),
...createSessionSlice(set),
...createCalendarSlice(set),
...createViewSlice(set),
batchUpdate: (partial) => set(partial),
}));

View File

@@ -0,0 +1,24 @@
import type { AppState } from "@/store/app-store";
export const sessionSelectors = {
lang: (s: AppState) => s.lang,
appContentReady: (s: AppState) => s.appContentReady,
isAdmin: (s: AppState) => s.isAdmin,
};
export const calendarSelectors = {
currentMonth: (s: AppState) => s.currentMonth,
pendingMonth: (s: AppState) => s.pendingMonth,
duties: (s: AppState) => s.duties,
calendarEvents: (s: AppState) => s.calendarEvents,
dataForMonthKey: (s: AppState) => s.dataForMonthKey,
selectedDay: (s: AppState) => s.selectedDay,
};
export const viewSelectors = {
loading: (s: AppState) => s.loading,
error: (s: AppState) => s.error,
accessDenied: (s: AppState) => s.accessDenied,
accessDeniedDetail: (s: AppState) => s.accessDeniedDetail,
currentView: (s: AppState) => s.currentView,
};

View File

@@ -0,0 +1,41 @@
import type { CalendarEvent, DutyWithUser } from "@/types";
import type { DataForMonthKey } from "@/store/types";
export interface CalendarSlice {
currentMonth: Date;
/** When set, we are loading this month; currentMonth and data stay until load completes. */
pendingMonth: Date | null;
duties: DutyWithUser[];
calendarEvents: CalendarEvent[];
/** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */
dataForMonthKey: DataForMonthKey;
setCurrentMonth: (d: Date) => void;
nextMonth: () => void;
prevMonth: () => void;
setDuties: (d: DutyWithUser[]) => void;
setCalendarEvents: (e: CalendarEvent[]) => void;
}
const now = new Date();
const initialMonth = new Date(now.getFullYear(), now.getMonth(), 1);
type CalendarSet = (updater: Partial<CalendarSlice> | ((state: CalendarSlice) => Partial<CalendarSlice>)) => void;
export const createCalendarSlice = (set: CalendarSet): CalendarSlice => ({
currentMonth: initialMonth,
pendingMonth: null,
duties: [],
calendarEvents: [],
dataForMonthKey: null,
setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1),
})),
prevMonth: () =>
set((s) => ({
pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1),
})),
setDuties: (d) => set({ duties: d }),
setCalendarEvents: (e) => set({ calendarEvents: e }),
});

View File

@@ -0,0 +1,21 @@
export interface SessionSlice {
lang: "ru" | "en";
/** True when the first visible screen has finished loading; used to hide content until ready(). */
appContentReady: boolean;
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
isAdmin: boolean;
setLang: (v: "ru" | "en") => void;
setAppContentReady: (v: boolean) => void;
setIsAdmin: (v: boolean) => void;
}
type SessionSet = (updater: Partial<SessionSlice> | ((state: SessionSlice) => Partial<SessionSlice>)) => void;
export const createSessionSlice = (set: SessionSet): SessionSlice => ({
lang: "en",
appContentReady: false,
isAdmin: false,
setLang: (v) => set({ lang: v }),
setAppContentReady: (v) => set({ appContentReady: v }),
setIsAdmin: (v) => set({ isAdmin: v }),
});

View File

@@ -0,0 +1,41 @@
import { getStartParamFromUrl } from "@/lib/launch-params";
import type { CurrentView } from "@/store/types";
export interface ViewSlice {
loading: boolean;
error: string | null;
accessDenied: boolean;
/** Server detail from API 403 response; shown in AccessDeniedScreen. */
accessDeniedDetail: string | null;
currentView: CurrentView;
selectedDay: string | null;
setLoading: (v: boolean) => void;
setError: (msg: string | null) => void;
setAccessDenied: (v: boolean) => void;
setAccessDeniedDetail: (v: string | null) => void;
setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void;
}
/** Initial view: currentDuty when opened via deep link (startParam=duty), else calendar. */
function getInitialView(): CurrentView {
if (typeof window === "undefined") return "calendar";
return getStartParamFromUrl() === "duty" ? "currentDuty" : "calendar";
}
type ViewSet = (updater: Partial<ViewSlice> | ((state: ViewSlice) => Partial<ViewSlice>)) => void;
export const createViewSlice = (set: ViewSet): ViewSlice => ({
loading: false,
error: null,
accessDenied: false,
accessDeniedDetail: null,
currentView: getInitialView(),
selectedDay: null,
setLoading: (v) => set({ loading: v }),
setError: (msg) => set({ error: msg }),
setAccessDenied: (v) => set({ accessDenied: v }),
setAccessDeniedDetail: (v) => set({ accessDeniedDetail: v }),
setCurrentView: (v) => set({ currentView: v }),
setSelectedDay: (key) => set({ selectedDay: key }),
});

View File

@@ -0,0 +1,4 @@
export type CurrentView = "calendar" | "currentDuty";
/** YYYY-MM key for the month that duties/calendarEvents belong to; null when none loaded. */
export type DataForMonthKey = string | null;