Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee77ee5c1 | |||
| 3f34c7951f | |||
| dc87b3ad97 | |||
| 7cd00893ad | |||
| 95d3af4930 | |||
| 24d6ecbedb | |||
| 34001d22d9 | |||
| 4d09c8641c | |||
| 172d145f0e | |||
| 45c65e3025 | |||
| fa22976e75 | |||
| 43cd3bbd7d | |||
| 26a9443e1b | |||
| 40e2b5adc4 | |||
| 76bff6dc05 | |||
| 6da6c87d3c | |||
| 02a586a1c5 | |||
| 53a899ea26 | |||
| a3152a4545 | |||
| c390a4dd6e | |||
| 68b1884b73 | |||
| fb786c4c3a | |||
| 07e22079ee | |||
| 13aba85e28 | |||
| 8ad8dffd0a | |||
| 6d087d1b26 | |||
| 94545dc8c3 | |||
| 33359f589a | |||
| 3244fbe505 | |||
| d99912b080 | |||
| 6adec62b5f | |||
| a8d4afb101 | |||
| 106e42a81d | |||
| 3c4c28a1ac | |||
| 119661628e | |||
| 336e6d48c5 | |||
| 07d08bb179 | |||
| 378daad503 | |||
| 54f85a8f14 | |||
| 8bf92bd4a1 | |||
| 68a153e4a7 | |||
| cac06f22fa | |||
| 87e8417675 | |||
| 37218a436a | |||
| 50d734e192 | |||
| edf0186682 | |||
| 6e2188787e | |||
| fd527917e0 | |||
| 95c9e23c33 | |||
| 95f65141e1 | |||
| 3b68e29d7b | |||
| 16bf1a1043 | |||
| 2de5c1cb81 | |||
| 70b9050cb7 | |||
| 7ffa727832 | |||
| 43386b15fa | |||
| 67ba9826c7 | |||
| 54446d7b0f | |||
| 37d4226beb |
150
.cursor/rules/backend.mdc
Normal file
150
.cursor/rules/backend.mdc
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
description: Rules for working with the Python backend (duty_teller/)
|
||||
globs:
|
||||
- duty_teller/**
|
||||
- alembic/**
|
||||
- tests/**
|
||||
---
|
||||
|
||||
# Backend — Python
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
duty_teller/
|
||||
├── main.py / run.py # Entry point: bot + uvicorn
|
||||
├── config.py # Settings from env vars (python-dotenv)
|
||||
├── cache.py # TTL caches with pattern invalidation
|
||||
├── api/ # FastAPI app, routes, auth, ICS endpoints
|
||||
│ ├── app.py # FastAPI app creation, route registration, static mount
|
||||
│ ├── dependencies.py # get_db_session, require_miniapp_username, get_validated_dates
|
||||
│ ├── telegram_auth.py # initData HMAC validation
|
||||
│ ├── calendar_ics.py # External ICS fetch & parse
|
||||
│ └── personal_calendar_ics.py
|
||||
├── db/ # Database layer
|
||||
│ ├── models.py # SQLAlchemy ORM models (Base)
|
||||
│ ├── repository.py # CRUD functions (receive Session)
|
||||
│ ├── schemas.py # Pydantic response schemas
|
||||
│ └── session.py # session_scope, get_engine, get_session
|
||||
├── handlers/ # Telegram bot command/message handlers
|
||||
│ ├── commands.py # /start, /help, /set_phone, /calendar_link, /set_role
|
||||
│ ├── common.py # is_admin_async, invalidate_is_admin_cache
|
||||
│ ├── errors.py # Global error handler
|
||||
│ ├── group_duty_pin.py # Pinned duty message in group chats
|
||||
│ └── import_duty_schedule.py
|
||||
├── i18n/ # Translations
|
||||
│ ├── core.py # get_lang, t()
|
||||
│ ├── lang.py # normalize_lang
|
||||
│ └── messages.py # MESSAGES dict (ru/en)
|
||||
├── importers/ # File parsers
|
||||
│ └── duty_schedule.py # parse_duty_schedule
|
||||
├── services/ # Business logic (receives Session)
|
||||
│ ├── import_service.py # run_import
|
||||
│ └── group_duty_pin_service.py
|
||||
└── utils/ # Shared helpers
|
||||
├── dates.py
|
||||
├── handover.py
|
||||
├── http_client.py
|
||||
└── user.py
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
- Use absolute imports from the `duty_teller` package: `from duty_teller.db.repository import get_or_create_user`.
|
||||
- Never import handler modules from services or repository — dependency flows
|
||||
downward: handlers → services → repository → models.
|
||||
|
||||
## DB access pattern
|
||||
|
||||
Handlers are async; SQLAlchemy sessions are synchronous. Two patterns:
|
||||
|
||||
### In bot handlers — `session_scope` + `run_in_executor`
|
||||
|
||||
```python
|
||||
def do_work():
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
# synchronous DB code here
|
||||
...
|
||||
|
||||
await asyncio.get_running_loop().run_in_executor(None, do_work)
|
||||
```
|
||||
|
||||
### In FastAPI endpoints — dependency injection
|
||||
|
||||
```python
|
||||
@router.get("/api/duties")
|
||||
def get_duties(session: Session = Depends(get_db_session)):
|
||||
...
|
||||
```
|
||||
|
||||
`get_db_session` yields a `session_scope` context — FastAPI closes it after the request.
|
||||
|
||||
## Handler patterns
|
||||
|
||||
### Admin-only commands
|
||||
|
||||
```python
|
||||
if not await is_admin_async(update.effective_user.id):
|
||||
await update.message.reply_text(t(lang, "import.admin_only"))
|
||||
return
|
||||
```
|
||||
|
||||
`is_admin_async` is cached for 60 s and invalidated on role changes.
|
||||
|
||||
### i18n in handlers
|
||||
|
||||
```python
|
||||
lang = get_lang(update.effective_user)
|
||||
text = t(lang, "start.greeting")
|
||||
```
|
||||
|
||||
`t(lang, key, **kwargs)` substitutes `{placeholders}` and falls back to English → raw key.
|
||||
|
||||
### Error handling
|
||||
|
||||
The global `error_handler` (registered via `app.add_error_handler`) logs the
|
||||
exception and sends a generic localized error reply.
|
||||
|
||||
## Service layer
|
||||
|
||||
- Services receive a `Session` — they never open their own.
|
||||
- Services call repository functions, never handler code.
|
||||
- After mutations that affect cached data, call `invalidate_duty_related_caches()`.
|
||||
|
||||
## Cache invalidation
|
||||
|
||||
Three TTL caches in `cache.py`:
|
||||
|
||||
| Cache | TTL | Invalidation trigger |
|
||||
|-------|-----|---------------------|
|
||||
| `ics_calendar_cache` | 600 s | After duty import |
|
||||
| `duty_pin_cache` | 90 s | After duty import |
|
||||
| `is_admin_cache` | 60 s | After `set_user_role` |
|
||||
|
||||
- `invalidate_duty_related_caches()` clears ICS and pin caches (call after any duty mutation).
|
||||
- `invalidate_is_admin_cache(telegram_user_id)` clears a single admin entry.
|
||||
- Pattern invalidation: `cache.invalidate_pattern(key_prefix_tuple)`.
|
||||
|
||||
## Alembic migrations
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.alembic]`, script location: `alembic/`.
|
||||
- **Naming convention:** `NNN_description.py` — sequential zero-padded number (`001`, `002`, …).
|
||||
- Revision IDs are the string number: `revision = "008"`, `down_revision = "007"`.
|
||||
- Run: `alembic -c pyproject.toml upgrade head` / `downgrade -1`.
|
||||
- `entrypoint.sh` runs `alembic upgrade head` before starting the app in Docker.
|
||||
|
||||
## Configuration
|
||||
|
||||
- All config comes from environment variables, loaded with `python-dotenv`.
|
||||
- `duty_teller/config.py` exposes module-level constants (`BOT_TOKEN`, `DATABASE_URL`, etc.)
|
||||
built from `Settings.from_env()`.
|
||||
- Never hardcode secrets or URLs — always use `config.*` constants.
|
||||
- Key variables: `BOT_TOKEN`, `DATABASE_URL`, `MINI_APP_BASE_URL`, `HTTP_HOST`, `HTTP_PORT`,
|
||||
`ADMIN_USERNAMES`, `ALLOWED_USERNAMES`, `DUTY_DISPLAY_TZ`, `DEFAULT_LANGUAGE`.
|
||||
|
||||
## Code style
|
||||
|
||||
- Formatter: Black (line-length 120).
|
||||
- Linter: Ruff (`ruff check duty_teller tests`).
|
||||
- Follow PEP 8 and Google Python Style Guide for docstrings.
|
||||
- Max line length: 120 characters.
|
||||
70
.cursor/rules/frontend.mdc
Normal file
70
.cursor/rules/frontend.mdc
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
description: Rules for working with the Telegram Mini App frontend (webapp-next/)
|
||||
globs:
|
||||
- webapp-next/**
|
||||
---
|
||||
|
||||
# Frontend — Telegram Mini App (Next.js)
|
||||
|
||||
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||
|
||||
## Structure
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
|
||||
| Providers | `src/components/providers/TelegramProvider.tsx` |
|
||||
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
|
||||
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
|
||||
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
|
||||
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
|
||||
| Store | `src/store/app-store.ts` |
|
||||
| Types | `src/types/index.ts` |
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||
- **i18n:** `useTranslation()` for React UI; `getLang()/translate()` only for early bootstrap or non-React boundaries.
|
||||
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||
- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`.
|
||||
- **Platform boundary:** Use `src/hooks/telegram/*` adapters for Back/Settings/Close/swipe/closing behavior instead of direct SDK control calls in feature components.
|
||||
- **Screen shells:** Reuse `src/components/layout/MiniAppScreen.tsx` wrappers for route and full-screen states.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||
|
||||
## Design guideline
|
||||
|
||||
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
|
||||
|
||||
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
|
||||
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
|
||||
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
|
||||
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
|
||||
- Keep all user-facing strings and `aria-label`/`sr-only` text localized.
|
||||
- Follow Telegram interaction policy from the design guideline: vertical swipes enabled by default, closing confirmation only for stateful flows.
|
||||
|
||||
Use the checklist in the design doc when introducing new screens or components.
|
||||
|
||||
Consider these rules when changing the Mini App or adding frontend features.
|
||||
113
.cursor/rules/project.mdc
Normal file
113
.cursor/rules/project.mdc
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
description: Overall project architecture and context for duty-teller
|
||||
globs:
|
||||
- "**"
|
||||
---
|
||||
|
||||
# Project — duty-teller
|
||||
|
||||
A Telegram bot for team duty shift calendar management and group reminders,
|
||||
with a Telegram Mini App (webapp) for calendar visualization.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌──────────────┐ polling ┌──────────────────────┐
|
||||
│ Telegram │◄────────────►│ python-telegram-bot │
|
||||
│ Bot API │ │ (handlers/) │
|
||||
└──────────────┘ └──────────┬───────────┘
|
||||
│
|
||||
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
||||
│ Telegram │◄────────────►│ FastAPI (api/) │
|
||||
│ Mini App │ initData │ + static webapp │
|
||||
│ (webapp-next/) │ auth └──────────┬───────────┘
|
||||
└──────────────┘ │
|
||||
┌──────────▼───────────┐
|
||||
│ SQLite + SQLAlchemy │
|
||||
│ (db/) │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
- **Bot:** python-telegram-bot v22, async polling mode.
|
||||
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
||||
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
||||
- **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
|
||||
|
||||
## Key packages
|
||||
|
||||
| Package | Role |
|
||||
|---------|------|
|
||||
| `duty_teller/handlers/` | Telegram bot command and message handlers |
|
||||
| `duty_teller/api/` | FastAPI app, REST endpoints, Telegram initData auth, ICS feeds |
|
||||
| `duty_teller/db/` | ORM models, repository (CRUD), session management, Pydantic schemas |
|
||||
| `duty_teller/services/` | Business logic (import, duty pin) — receives Session |
|
||||
| `duty_teller/importers/` | Duty schedule file parsers |
|
||||
| `duty_teller/i18n/` | Bilingual translations (ru/en), `t()` function |
|
||||
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
||||
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
||||
| `duty_teller/config.py` | Environment-based configuration |
|
||||
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Auth | Purpose |
|
||||
|--------|------|------|---------|
|
||||
| GET | `/health` | None | Health check |
|
||||
| GET | `/api/duties` | initData | Duties for date range |
|
||||
| GET | `/api/calendar-events` | initData | External calendar events |
|
||||
| GET | `/api/calendar/ical/team/{token}.ics` | Token | Team ICS feed |
|
||||
| GET | `/api/calendar/ical/{token}.ics` | Token | Personal ICS feed |
|
||||
| GET | `/app` | None | Static Mini App (HTML/JS/CSS) |
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker:** Multi-stage build (`Dockerfile`), `docker-compose.prod.yml` for production.
|
||||
- **Entrypoint:** `entrypoint.sh` runs `alembic -c pyproject.toml upgrade head`, then
|
||||
starts `python main.py` as `botuser`.
|
||||
- **Data:** SQLite DB persisted via Docker volume at `/app/data`.
|
||||
- **Health:** `curl -f http://localhost:8080/health` every 30 s.
|
||||
|
||||
## CI/CD (Gitea Actions)
|
||||
|
||||
### Lint & test (`.gitea/workflows/ci.yml`)
|
||||
|
||||
Triggered on push/PR to `main` and `develop`:
|
||||
|
||||
1. **Ruff:** `ruff check duty_teller tests`
|
||||
2. **Pytest:** `pytest tests/ -v` (80% coverage gate)
|
||||
3. **Bandit:** `bandit -r duty_teller -ll` (security scan)
|
||||
|
||||
### Docker build & release (`.gitea/workflows/docker-build.yml`)
|
||||
|
||||
Triggered on `v*` tags:
|
||||
|
||||
1. Build and push image to Gitea registry.
|
||||
2. Create release with auto-generated notes.
|
||||
|
||||
## Key environment variables
|
||||
|
||||
| Variable | Default | Required | Purpose |
|
||||
|----------|---------|----------|---------|
|
||||
| `BOT_TOKEN` | — | Yes | Telegram bot API token |
|
||||
| `DATABASE_URL` | `sqlite:///data/duty_teller.db` | No | SQLAlchemy database URL |
|
||||
| `MINI_APP_BASE_URL` | — | Yes (prod) | Public URL for the Mini App |
|
||||
| `HTTP_HOST` | `127.0.0.1` | No | FastAPI bind host |
|
||||
| `HTTP_PORT` | `8080` | No | FastAPI bind port |
|
||||
| `ADMIN_USERNAMES` | — | No | Comma-separated admin Telegram usernames |
|
||||
| `ALLOWED_USERNAMES` | — | No | Comma-separated allowed usernames |
|
||||
| `DUTY_DISPLAY_TZ` | `Europe/Moscow` | No | Timezone for duty display |
|
||||
| `DEFAULT_LANGUAGE` | `en` | No | Default UI language (`en` or `ru`) |
|
||||
| `EXTERNAL_CALENDAR_ICS_URL` | — | No | External ICS URL for holidays |
|
||||
| `CORS_ORIGINS` | `*` | No | Comma-separated CORS origins |
|
||||
|
||||
## Languages
|
||||
|
||||
- **Backend:** Python 3.12+
|
||||
- **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
|
||||
- **i18n:** Russian (default) and English
|
||||
|
||||
## Version control
|
||||
|
||||
- Git with Gitea Flow branching strategy.
|
||||
- Conventional Commits for commit messages.
|
||||
- PRs reviewed via Gitea Pull Requests.
|
||||
83
.cursor/rules/testing.mdc
Normal file
83
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
description: Rules for writing and running tests
|
||||
globs:
|
||||
- tests/**
|
||||
- webapp-next/src/**/*.test.{ts,tsx}
|
||||
---
|
||||
|
||||
# Testing
|
||||
|
||||
## Python tests (pytest)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||
- `asyncio_mode = "auto"` — async test functions are detected automatically, no decorator needed.
|
||||
- Coverage: `--cov=duty_teller --cov-fail-under=80`.
|
||||
- Run: `pytest tests/ -v` from project root.
|
||||
|
||||
### File layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures (in-memory DB, sessions, bot app)
|
||||
├── helpers.py # Test helper functions
|
||||
└── test_*.py # Test modules
|
||||
```
|
||||
|
||||
### Key fixtures (conftest.py)
|
||||
|
||||
- `conftest.py` sets `BOT_TOKEN=test-token-for-pytest` if the env var is missing,
|
||||
so tests run without a real token.
|
||||
- Database fixtures use in-memory SQLite for isolation.
|
||||
|
||||
### Writing Python tests
|
||||
|
||||
- File naming: `tests/test_<module>.py` (e.g. `tests/test_import_service.py`).
|
||||
- Function naming: `test_<what>_<scenario>` or `test_<what>_<expected_result>`.
|
||||
- Use `session_scope` with the test database URL for DB tests.
|
||||
- Async tests: just use `async def test_...` — `asyncio_mode = "auto"` handles it.
|
||||
- Mock external dependencies (Telegram API, HTTP calls) with `unittest.mock` or `pytest-mock`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from duty_teller.db.session import session_scope
|
||||
|
||||
def test_get_or_create_user_creates_new(test_db_url):
|
||||
with session_scope(test_db_url) as session:
|
||||
user = get_or_create_user(session, telegram_user_id=123, full_name="Test")
|
||||
assert user.telegram_user_id == 123
|
||||
```
|
||||
|
||||
## Frontend tests (Vitest + React Testing Library)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config: `webapp-next/vitest.config.ts`.
|
||||
- Environment: jsdom; React Testing Library for components.
|
||||
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
|
||||
- Setup: `webapp-next/src/test/setup.ts`.
|
||||
- Run: `cd webapp-next && npm test` (or `npm run test`).
|
||||
|
||||
### Writing frontend tests
|
||||
|
||||
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
|
||||
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
|
||||
- Mock Telegram SDK and API fetch where necessary.
|
||||
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localDateString } from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
const d = new Date(2025, 0, 15);
|
||||
expect(localDateString(d)).toBe("2025-01-15");
|
||||
});
|
||||
});
|
||||
```
|
||||
55
.cursor/skills/project-release/SKILL.md
Normal file
55
.cursor/skills/project-release/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: project-release
|
||||
description: Performs a project release by updating CHANGELOG for the new version, committing all changes, and pushing a version tag. Use when the user asks to release, cut a release, publish a version, or to update changelog and push a tag.
|
||||
---
|
||||
|
||||
# Project Release
|
||||
|
||||
Release workflow for duty-teller: update changelog, commit, tag, and push. Triggers Gitea Actions (Docker build) on `v*` tags.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Decide the **new version** (e.g. `2.1.0`). Use [Semantic Versioning](https://semver.org/).
|
||||
- Ensure `CHANGELOG.md` has entries under `## [Unreleased]` (or add a short note like "No changes" if intentional).
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Update CHANGELOG.md
|
||||
|
||||
- Replace the `## [Unreleased]` section with a dated release section:
|
||||
- `## [X.Y.Z] - YYYY-MM-DD` (use today's date in `YYYY-MM-DD`).
|
||||
- Leave a new empty `## [Unreleased]` section after it (for future edits).
|
||||
- At the bottom of the file, add the comparison link for the new version:
|
||||
- `[X.Y.Z]: https://github.com/your-org/duty-teller/releases/tag/vX.Y.Z`
|
||||
- (Replace `your-org/duty-teller` with the real repo URL if different.)
|
||||
- Update the `[Unreleased]` link to compare against this release, e.g.:
|
||||
- `[Unreleased]: https://github.com/your-org/duty-teller/compare/vX.Y.Z...HEAD`
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); keep existing subsections (Added, Changed, Security, etc.) under the new version.
|
||||
|
||||
### 2. Bump version in pyproject.toml (optional)
|
||||
|
||||
- Set `version = "X.Y.Z"` in `[project]` so it matches the release. Skip if the project does not sync version here.
|
||||
|
||||
### 3. Commit and tag
|
||||
|
||||
- Stage all changes: `git add -A`
|
||||
- Commit with Conventional Commits: `git commit -m "chore(release): vX.Y.Z"`
|
||||
- Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||
- Push branch: `git push origin main` (or current branch)
|
||||
- Push tag: `git push origin vX.Y.Z`
|
||||
|
||||
Pushing the `v*` tag triggers `.gitea/workflows/docker-build.yml` (Docker build and release).
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] CHANGELOG: `[Unreleased]` → `[X.Y.Z] - YYYY-MM-DD`, new empty `[Unreleased]`, links at bottom updated
|
||||
- [ ] pyproject.toml version set to X.Y.Z (if used)
|
||||
- [ ] `git add -A` && `git commit -m "chore(release): vX.Y.Z"`
|
||||
- [ ] `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||
- [ ] `git push origin main` && `git push origin vX.Y.Z`
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not push tags from unreleased or uncommitted changelog.
|
||||
- If the repo URL in CHANGELOG links is a placeholder, keep it or ask the user for the correct base URL.
|
||||
60
.cursor/skills/run-tests/SKILL.md
Normal file
60
.cursor/skills/run-tests/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
||||
---
|
||||
name: run-tests
|
||||
description: Runs backend (pytest) and frontend (Vitest) tests for the duty-teller project. Use when the user asks to run tests, verify changes, or run pytest/vitest.
|
||||
---
|
||||
|
||||
# Run tests
|
||||
|
||||
## When to use
|
||||
|
||||
- User asks to "run tests", "run the test suite", or "verify tests pass".
|
||||
- After making code changes and user wants to confirm nothing is broken.
|
||||
- User explicitly asks for backend tests (pytest) or frontend tests (vitest/npm test).
|
||||
|
||||
## Backend tests (Python)
|
||||
|
||||
From the **repository root**:
|
||||
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
If imports fail, set `PYTHONPATH`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=. pytest
|
||||
```
|
||||
|
||||
- Config: `pyproject.toml` → `[tool.pytest.ini_options]` (coverage on `duty_teller`, 80% gate, asyncio mode).
|
||||
- Tests live in `tests/`.
|
||||
|
||||
## Frontend tests (Next.js / Vitest)
|
||||
|
||||
From the **repository root**:
|
||||
|
||||
```bash
|
||||
cd webapp-next && npm test
|
||||
```
|
||||
|
||||
- Runner: Vitest (`vitest run`); env: jsdom; React Testing Library.
|
||||
- Config: `webapp-next/vitest.config.ts`; setup: `webapp-next/src/test/setup.ts`.
|
||||
|
||||
## Running both
|
||||
|
||||
To run backend and frontend tests in sequence:
|
||||
|
||||
```bash
|
||||
pytest && (cd webapp-next && npm test)
|
||||
```
|
||||
|
||||
If the user did not specify "backend only" or "frontend only", run both and report results for each.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Single file or dir:** `pytest path/to/test_file.py` or `pytest path/to/test_dir/`. For frontend, use Vitest’s path args as per its docs (e.g. under `webapp-next/`).
|
||||
- **Verbosity:** Use `pytest -v` if the user wants more detail.
|
||||
|
||||
## Failures
|
||||
|
||||
- Do not send raw exception strings from tests to the user; summarize failures and point to failing test names/locations.
|
||||
- If pytest fails with import errors, suggest `PYTHONPATH=. pytest` and ensure the venv is activated and dev deps are installed (`pip install -r requirements-dev.txt` or `pip install -e ".[dev]"`).
|
||||
5
.cursor/worktrees.json
Normal file
5
.cursor/worktrees.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"setup-worktree": [
|
||||
"npm install"
|
||||
]
|
||||
}
|
||||
@@ -26,7 +26,10 @@ ADMIN_USERNAMES=admin1,admin2
|
||||
# When the pinned duty message is updated on schedule, re-pin so members get a notification (default: 1). Set to 0 or false to disable.
|
||||
# DUTY_PIN_NOTIFY=1
|
||||
|
||||
# Default UI language when user language is unknown: en or ru (default: en).
|
||||
# Log level for backend and Mini App console logs: DEBUG, INFO, WARNING, ERROR. Default: INFO.
|
||||
# LOG_LEVEL=INFO
|
||||
|
||||
# Single source of language for bot, API, and Mini App (en or ru). Default: en. No auto-detection.
|
||||
# DEFAULT_LANGUAGE=en
|
||||
|
||||
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
||||
|
||||
@@ -26,6 +26,15 @@ jobs:
|
||||
run: |
|
||||
pip install ruff bandit
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp (Next.js) build and test
|
||||
run: |
|
||||
cd webapp-next && npm ci && npm test && npm run build
|
||||
|
||||
- name: Lint with Ruff
|
||||
run: |
|
||||
ruff check duty_teller tests
|
||||
@@ -39,14 +48,3 @@ jobs:
|
||||
- name: Security check with Bandit
|
||||
run: |
|
||||
bandit -r duty_teller -ll
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp tests
|
||||
run: |
|
||||
cd webapp
|
||||
npm ci
|
||||
npm run test
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -7,11 +7,18 @@ venv/
|
||||
*.pyo
|
||||
data/
|
||||
*.db
|
||||
.cursor/
|
||||
.cursorrules/
|
||||
|
||||
# Test and coverage artifacts
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
*.cover
|
||||
*.plan.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Next.js webapp
|
||||
webapp-next/out/
|
||||
webapp-next/node_modules/
|
||||
webapp-next/.next/
|
||||
54
AGENTS.md
Normal file
54
AGENTS.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Duty Teller — AI agent documentation
|
||||
|
||||
This file is for AI assistants (e.g. Cursor) and maintainers. All project documentation and docstrings must be in **English**. User-facing UI strings remain localized (ru/en) in `duty_teller/i18n/`.
|
||||
|
||||
## Project summary
|
||||
|
||||
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
|
||||
|
||||
## Key entry points
|
||||
|
||||
- **CLI / process:** `main.py` or, after `pip install -e .`, the `duty-teller` console command. Both delegate to `duty_teller.run.main()`.
|
||||
- **Application setup:** `duty_teller/run.py` — builds the Telegram `Application`, registers handlers via `register_handlers(app)`, runs polling and FastAPI in a thread, calls `config.require_bot_token()` so the app exits clearly if `BOT_TOKEN` is missing.
|
||||
- **HTTP API:** `duty_teller/api/app.py` — FastAPI app, route registration, static webapp mounted at `/app`.
|
||||
|
||||
## Where to change what
|
||||
|
||||
| Area | Location |
|
||||
|------|----------|
|
||||
| Telegram handlers | `duty_teller/handlers/` |
|
||||
| REST API | `duty_teller/api/` |
|
||||
| Business logic | `duty_teller/services/` |
|
||||
| Database (models, repository, schemas) | `duty_teller/db/` |
|
||||
| Translations (ru/en) | `duty_teller/i18n/` |
|
||||
| Duty-schedule parser | `duty_teller/importers/` |
|
||||
| Config (env vars) | `duty_teller/config.py` |
|
||||
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
|
||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||
|
||||
## Running and testing
|
||||
|
||||
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
|
||||
- **Lint:** `ruff check duty_teller tests`
|
||||
- **Security:** `bandit -r duty_teller -ll`
|
||||
|
||||
## Documentation
|
||||
|
||||
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
|
||||
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). UI strings are the only exception; they live in `duty_teller/i18n/`.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`).
|
||||
- **Branches:** Gitea Flow; changes via Pull Request.
|
||||
- **Testing:** pytest, 80% coverage target; unit and integration tests.
|
||||
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
||||
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
||||
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
||||
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases, use shared Mini App screen shells, keep Telegram SDK access behind `src/hooks/telegram/*`, apply safe-area/content-safe-area and accessibility rules, and run the Mini App verification matrix (light/dark, iOS/Android safe area, low-perf Android, deep links, admin flow, fallback states).
|
||||
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -7,9 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.1.3] - 2025-03-07
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.1.2] - 2025-03-06
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.1.1] - 2025-03-06
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.6] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.4] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.3] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.2] - 2025-03-04
|
||||
|
||||
(No changes documented; release for version sync.)
|
||||
|
||||
## [2.0.0] - 2026-03-03
|
||||
|
||||
### Added
|
||||
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to only edit the message without re-pinning.
|
||||
- **Group duty pin**: when the pinned duty message is updated on schedule, the bot re-pins it so group members get a Telegram notification. Configurable via `DUTY_PIN_NOTIFY` (default: enabled); set to `0` or `false` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent.
|
||||
- **Command `/refresh_pin`**: in a group, immediately refresh the pinned duty message (send new message, unpin old, pin new).
|
||||
- **Role-based access and `/set_role`**: Miniapp and admin access are determined by roles stored in the database (`roles` table, `users.role_id`). Roles: `user` (miniapp access), `admin` (miniapp + `/import_duty_schedule`, `/set_role`). Admins assign roles with `/set_role @username user|admin` (or reply to a message with `/set_role user|admin`). `ALLOWED_USERNAMES` and `ALLOWED_PHONES` are no longer used for access (kept for reference).
|
||||
- **Command `/calendar_link`**: in private chat, send the user their personal ICS subscription URL (and team calendar URL) for calendar apps.
|
||||
- **Config `MINI_APP_SHORT_NAME`**: when set, the pinned duty message "View contacts" button uses a direct Mini App link (`https://t.me/BotName/ShortName?startapp=duty`) so the app opens on the current-duty view.
|
||||
- **Config `LOG_LEVEL`**: control backend logging and the Miniapp console logger (`window.__DT_LOG_LEVEL`); one of `DEBUG`, `INFO`, `WARNING`, `ERROR` (default: `INFO`).
|
||||
- **Mini App**: migrated to Next.js (TypeScript, Tailwind, shadcn/ui) with static export; improved loading states, duty timeline styling, and content readiness handling; configurable loopback host for health checks.
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,4 +72,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Input validation and initData hash verification for Miniapp access.
|
||||
- Optional CORS and init_data_max_age; use env for secrets.
|
||||
|
||||
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.3...HEAD
|
||||
[2.1.3]: https://github.com/your-org/duty-teller/releases/tag/v2.1.3
|
||||
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
|
||||
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||
|
||||
@@ -53,6 +53,15 @@
|
||||
bandit -r duty_teller -ll
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
All project documentation must be in **English**. This includes:
|
||||
|
||||
- README, files in `docs/`, docstrings, and commit messages that touch documentation.
|
||||
- Exception: user-facing UI strings are localized (Russian/English) in `duty_teller/i18n/` and are not considered project documentation.
|
||||
|
||||
Docstrings and code comments must be in English (Google-style docstrings). See [AGENTS.md](AGENTS.md) for AI/maintainer context.
|
||||
|
||||
## Commit messages
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/), e.g.:
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,22 @@
|
||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||
# Multi-stage: webapp build (Next.js), Python builder, runtime.
|
||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||
|
||||
# --- Stage 1: builder (dependencies only) ---
|
||||
# --- Stage 1: webapp build (Next.js static export) ---
|
||||
FROM node:20-slim AS webapp-builder
|
||||
WORKDIR /webapp
|
||||
COPY webapp-next/package.json webapp-next/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY webapp-next/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: builder (Python dependencies only) ---
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# --- Stage 2: runtime (minimal final image) ---
|
||||
# --- Stage 3: runtime (minimal final image) ---
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY webapp/ ./webapp/
|
||||
COPY --from=webapp-builder /webapp/out ./webapp-next/out
|
||||
|
||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||
RUN adduser --disabled-password --gecos "" botuser \
|
||||
|
||||
@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `webapp-next/` – Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
@@ -151,6 +151,7 @@ Tests cover `api/telegram_auth` (validate_init_data, auth_date expiry), `config`
|
||||
- **Commits:** Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`, etc.
|
||||
- **Branches:** Follow [Gitea Flow](https://docs.gitea.io/en-us/workflow-branching/): main branch `main`, features and fixes in separate branches.
|
||||
- **Changes:** Via **Pull Request** in Gitea; run linters and tests (`ruff check .`, `pytest`) before merge.
|
||||
- **Documentation:** Project documentation is in English; see [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
## Logs and rotation
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
## Components
|
||||
|
||||
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
|
||||
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
||||
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
||||
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
||||
@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
- **Miniapp → API**
|
||||
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
||||
|
||||
- **Admin panel (Mini App)**
|
||||
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
|
||||
|
||||
- **Import**
|
||||
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
||||
|
||||
|
||||
@@ -5,21 +5,23 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
|
||||
| Variable | Type / format | Default | Description |
|
||||
|----------|----------------|---------|-------------|
|
||||
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
||||
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
|
||||
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
||||
| **HTTP_PORT** | integer | `8080` | Port for the HTTP server (FastAPI + static webapp). |
|
||||
| **HTTP_PORT** | integer (1–65535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
|
||||
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
||||
| **ADMIN_USERNAMES** | comma-separated list | *(empty)* | Telegram usernames treated as **admin fallback** when the user has **no role in the DB**. If a user has a role in the DB, only that role applies. Example: `admin1,admin2`. |
|
||||
| **ALLOWED_PHONES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. |
|
||||
| **ADMIN_PHONES** | comma-separated list | *(empty)* | Phones treated as **admin fallback** when the user has **no role in the DB** (user sets phone via `/set_phone`). Comparison uses digits only. Example: `+7 999 123-45-67`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. Example: `https://your-domain.com`. |
|
||||
| **MINI_APP_SKIP_AUTH** | `1`, `true`, or `yes` | *(unset)* | If set, `/api/duties` and `/api/calendar-events` are allowed without Telegram initData. **Dev only — never use in production.** The process exits with an error if this is set and **HTTP_HOST** is not localhost (127.0.0.1). |
|
||||
| **INIT_DATA_MAX_AGE_SECONDS** | integer (≥ 0) | `0` | Reject Telegram initData older than this many seconds. `0` = disabled. Invalid values fall back to 0. Example: `86400` for 24 hours. |
|
||||
| **CORS_ORIGINS** | comma-separated list | `*` | Allowed origins for CORS. Leave unset or set to `*` for allow-all. **In production**, set an explicit list (e.g. `https://your-domain.com`) instead of `*` to avoid allowing arbitrary origins. Example: `https://your-domain.com`. |
|
||||
| **EXTERNAL_CALENDAR_ICS_URL** | string (URL) | *(empty)* | URL of a public ICS calendar (e.g. holidays). If set, those days are highlighted on the duty grid; users can tap "i" on a cell to see the event summary. Empty = no external calendar. |
|
||||
| **DUTY_DISPLAY_TZ** | string (timezone name) | `Europe/Moscow` | Timezone for the pinned duty message in groups. Example: `Europe/Moscow`, `UTC`. |
|
||||
| **DUTY_PIN_NOTIFY** | `0`, `false`, or `no` to disable | `1` (enabled) | When the pinned duty message is updated on schedule, the bot sends a new message, unpins the old one and pins the new one. If enabled, pinning the new message sends a Telegram notification (“Bot pinned a message”). Set to `0`, `false`, or `no` to pin without notification. The first pin (e.g. when the bot is added to the group or on `/pin_duty`) is always silent. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | Default UI language when the user's Telegram language is unknown. Values starting with `ru` are normalized to `ru`, otherwise `en`. |
|
||||
| **DEFAULT_LANGUAGE** | `en` or `ru` (normalized) | `en` | **Single source of language for the whole deployment:** bot messages, API error texts, and Mini App UI all use this value. No auto-detection from Telegram user, browser, or `Accept-Language`. Values starting with `ru` are normalized to `ru`; anything else becomes `en`. |
|
||||
| **LOG_LEVEL** | `DEBUG`, `INFO`, `WARNING`, or `ERROR` | `INFO` | Logging level for the backend (Python `logging`) and for the Mini App console logger (`window.__DT_LOG_LEVEL`). Use `DEBUG` for troubleshooting; in production `INFO` or higher is recommended. |
|
||||
|
||||
## Roles and access
|
||||
|
||||
|
||||
@@ -6,8 +6,11 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
||||
|
||||
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
||||
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
||||
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
|
||||
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
||||
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
||||
|
||||
For quick start, setup, and API overview see the main [README](../README.md).
|
||||
|
||||
**For maintainers and AI:** Project documentation and docstrings must be in English; see [CONTRIBUTING.md](../CONTRIBUTING.md#documentation). [AGENTS.md](../AGENTS.md) in the repo root provides entry points, conventions, and where to change what.
|
||||
|
||||
263
docs/miniapp-design.md
Normal file
263
docs/miniapp-design.md
Normal 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 [Telegram’s 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
|
||||
|
||||
Telegram’s 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 user’s 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 Telegram’s 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 Telegram’s 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 Telegram’s guidelines and WCAG).
|
||||
|
||||
---
|
||||
|
||||
## 8. Telegram integration
|
||||
|
||||
- **Ready gate:** `callMiniAppReadyOnce()` (in `lib/telegram-ready.ts`) is invoked by the layout’s `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 provider’s 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.
|
||||
119
docs/webapp-next-refactor-audit.md
Normal file
119
docs/webapp-next-refactor-audit.md
Normal 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.
|
||||
@@ -5,29 +5,43 @@ import re
|
||||
from datetime import date, timedelta
|
||||
|
||||
import duty_teller.config as config
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import Response
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from duty_teller.api.calendar_ics import get_calendar_events
|
||||
from duty_teller.api.dependencies import (
|
||||
_lang_from_accept_language,
|
||||
fetch_duties_response,
|
||||
get_authenticated_telegram_id_dep,
|
||||
get_db_session,
|
||||
get_validated_dates,
|
||||
require_admin_telegram_id,
|
||||
require_miniapp_username,
|
||||
)
|
||||
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
||||
from duty_teller.cache import ics_calendar_cache
|
||||
from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
|
||||
from duty_teller.db.repository import (
|
||||
get_duties,
|
||||
get_duties_for_user,
|
||||
get_duty_by_id,
|
||||
get_user_by_calendar_token,
|
||||
get_users_for_admin,
|
||||
is_admin_for_telegram_user,
|
||||
update_duty_user,
|
||||
)
|
||||
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
||||
from duty_teller.db.models import User
|
||||
from duty_teller.db.schemas import (
|
||||
AdminDutyReassignBody,
|
||||
CalendarEvent,
|
||||
DutyInDb,
|
||||
DutyWithUser,
|
||||
UserForAdmin,
|
||||
)
|
||||
from duty_teller.i18n import t
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,6 +57,16 @@ def _is_valid_calendar_token(token: str) -> bool:
|
||||
app = FastAPI(title="Duty Teller API")
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
def global_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Log unhandled exceptions and return 500 without exposing details to the client."""
|
||||
log.exception("Unhandled exception: %s", exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", summary="Health check")
|
||||
def health() -> dict:
|
||||
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
||||
@@ -58,22 +82,103 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
|
||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
||||
class NoCacheStaticMiddleware:
|
||||
"""
|
||||
Raw ASGI middleware: Cache-Control: no-store for all /app and /app/* static files;
|
||||
Vary: Accept-Language on all responses so reverse proxies do not serve one user's response to another.
|
||||
"""
|
||||
|
||||
async def dispatch(self, request, call_next):
|
||||
response = await call_next(request)
|
||||
path = request.url.path
|
||||
if path.startswith("/app/") and (
|
||||
path.endswith(".js") or path.endswith(".html")
|
||||
):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
def __init__(self, app, **kwargs):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
is_app_path = path == "/app" or path.startswith("/app/")
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
header_names = {h[0].lower(): i for i, h in enumerate(headers)}
|
||||
if is_app_path:
|
||||
cache_control = (b"cache-control", b"no-store")
|
||||
if b"cache-control" in header_names:
|
||||
headers[header_names[b"cache-control"]] = cache_control
|
||||
else:
|
||||
headers.append(cache_control)
|
||||
vary_val = b"Accept-Language"
|
||||
if b"vary" in header_names:
|
||||
idx = header_names[b"vary"]
|
||||
existing = headers[idx][1]
|
||||
tokens = [p.strip() for p in existing.split(b",")]
|
||||
if vary_val not in tokens:
|
||||
headers[idx] = (b"vary", existing + b", " + vary_val)
|
||||
else:
|
||||
headers.append((b"vary", vary_val))
|
||||
message = {
|
||||
"type": "http.response.start",
|
||||
"status": message["status"],
|
||||
"headers": headers,
|
||||
}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_wrapper)
|
||||
|
||||
|
||||
app.add_middleware(NoCacheStaticMiddleware)
|
||||
|
||||
|
||||
# Allowed values for config.js to prevent script injection.
|
||||
_VALID_LANGS = frozenset({"en", "ru"})
|
||||
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
|
||||
|
||||
|
||||
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
|
||||
if value in allowed:
|
||||
return value
|
||||
return default
|
||||
|
||||
|
||||
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||
|
||||
|
||||
def _safe_tz_string(value: str) -> str:
|
||||
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language, log level, timezone)",
|
||||
description=(
|
||||
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
|
||||
"Loaded before main.js."
|
||||
),
|
||||
)
|
||||
def app_config_js() -> Response:
|
||||
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
|
||||
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||
body = (
|
||||
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}\n'
|
||||
'if (typeof window !== "undefined") window.dispatchEvent(new Event("dt-config-loaded"));'
|
||||
)
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/duties",
|
||||
response_model=list[DutyWithUser],
|
||||
@@ -132,10 +237,10 @@ def get_team_calendar_ical(
|
||||
) -> Response:
|
||||
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("team_ics",)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
@@ -173,10 +278,10 @@ def get_personal_calendar_ical(
|
||||
No Telegram auth; access is by secret token in the URL.
|
||||
"""
|
||||
if not _is_valid_calendar_token(token):
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
user = get_user_by_calendar_token(session, token)
|
||||
if user is None:
|
||||
return Response(status_code=404, content="Not found")
|
||||
return JSONResponse(status_code=404, content={"detail": "Not found"})
|
||||
cache_key = ("personal_ics", user.id)
|
||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||
if not found:
|
||||
@@ -194,6 +299,96 @@ def get_personal_calendar_ical(
|
||||
)
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
||||
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/admin/me",
|
||||
summary="Check admin status",
|
||||
description=(
|
||||
"Returns is_admin for the authenticated Mini App user. "
|
||||
"Requires valid initData (same as /api/duties)."
|
||||
),
|
||||
)
|
||||
def admin_me(
|
||||
_username: str = Depends(require_miniapp_username),
|
||||
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> dict:
|
||||
"""Return { is_admin: true } or { is_admin: false } for the current user."""
|
||||
is_admin = is_admin_for_telegram_user(session, telegram_id)
|
||||
return {"is_admin": is_admin}
|
||||
|
||||
|
||||
@app.get(
|
||||
"/api/admin/users",
|
||||
response_model=list[UserForAdmin],
|
||||
summary="List users for admin dropdown",
|
||||
description="Returns id, full_name, username for all users. Admin only.",
|
||||
)
|
||||
def admin_list_users(
|
||||
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> list[UserForAdmin]:
|
||||
"""Return all users ordered by full_name for admin reassign dropdown."""
|
||||
users = get_users_for_admin(session)
|
||||
return [
|
||||
UserForAdmin(
|
||||
id=u.id,
|
||||
full_name=u.full_name,
|
||||
username=u.username,
|
||||
role_id=u.role_id,
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@app.patch(
|
||||
"/api/admin/duties/{duty_id}",
|
||||
response_model=DutyInDb,
|
||||
summary="Reassign duty to another user",
|
||||
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
|
||||
)
|
||||
def admin_reassign_duty(
|
||||
duty_id: int,
|
||||
body: AdminDutyReassignBody,
|
||||
request: Request,
|
||||
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> DutyInDb:
|
||||
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
if duty_id <= 0 or body.user_id <= 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=t(lang, "api.bad_request"),
|
||||
)
|
||||
duty = get_duty_by_id(session, duty_id)
|
||||
if duty is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=t(lang, "admin.duty_not_found"),
|
||||
)
|
||||
if session.get(User, body.user_id) is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=t(lang, "admin.user_not_found"),
|
||||
)
|
||||
updated = update_duty_user(session, duty_id, body.user_id, commit=True)
|
||||
if updated is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=t(lang, "admin.duty_not_found"),
|
||||
)
|
||||
invalidate_duty_related_caches()
|
||||
return DutyInDb(
|
||||
id=updated.id,
|
||||
user_id=updated.user_id,
|
||||
start_at=updated.start_at,
|
||||
end_at=updated.end_at,
|
||||
)
|
||||
|
||||
|
||||
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if webapp_path.is_dir():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Annotated, Generator
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||
@@ -13,46 +12,23 @@ from duty_teller.db.repository import (
|
||||
get_duties,
|
||||
get_user_by_telegram_id,
|
||||
can_access_miniapp_for_telegram_user,
|
||||
is_admin_for_telegram_user,
|
||||
)
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||
from duty_teller.db.session import session_scope
|
||||
from duty_teller.i18n import t
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Extract primary language code from first Accept-Language tag (e.g. "ru-RU" -> "ru").
|
||||
_ACCEPT_LANG_CODE_RE = re.compile(r"^([a-zA-Z]{2,3})(?:-|;|,|\s|$)")
|
||||
|
||||
|
||||
def _parse_first_language_code(header: str | None) -> str | None:
|
||||
"""Extract the first language code from Accept-Language header.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
Two- or three-letter code (e.g. 'ru', 'en') or None if missing/invalid.
|
||||
"""
|
||||
if not header or not header.strip():
|
||||
return None
|
||||
first = header.strip().split(",")[0].strip()
|
||||
m = _ACCEPT_LANG_CODE_RE.match(first)
|
||||
return m.group(1).lower() if m else None
|
||||
|
||||
|
||||
def _lang_from_accept_language(header: str | None) -> str:
|
||||
"""Normalize Accept-Language header to 'ru' or 'en'; fallback to config.DEFAULT_LANGUAGE.
|
||||
"""Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
Args:
|
||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
||||
|
||||
Returns:
|
||||
'ru' or 'en'.
|
||||
The header argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
code = _parse_first_language_code(header)
|
||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
||||
return config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||
@@ -67,7 +43,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
||||
try:
|
||||
validate_date_range(from_date, to_date)
|
||||
except DateRangeValidationError as e:
|
||||
key = "dates.bad_format" if e.kind == "bad_format" else "dates.from_after_to"
|
||||
key_map = {
|
||||
"bad_format": "dates.bad_format",
|
||||
"from_after_to": "dates.from_after_to",
|
||||
"range_too_large": "dates.range_too_large",
|
||||
}
|
||||
key = key_map.get(e.kind, "dates.bad_format")
|
||||
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
||||
except ValueError as e:
|
||||
# Backward compatibility if something else raises ValueError.
|
||||
@@ -179,6 +160,103 @@ def get_authenticated_username(
|
||||
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
||||
|
||||
|
||||
def get_authenticated_telegram_id(
|
||||
request: Request,
|
||||
x_telegram_init_data: str | None,
|
||||
session: Session,
|
||||
) -> int:
|
||||
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
|
||||
|
||||
Same validation as get_authenticated_username. Used to check is_admin.
|
||||
|
||||
Args:
|
||||
request: FastAPI request (for Accept-Language in error messages).
|
||||
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
return 0
|
||||
init_data = (x_telegram_init_data or "").strip()
|
||||
if not init_data:
|
||||
log.warning("no X-Telegram-Init-Data header")
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
||||
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
||||
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||
)
|
||||
if auth_reason != "ok":
|
||||
log.warning("initData validation failed: %s", auth_reason)
|
||||
raise HTTPException(
|
||||
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
||||
)
|
||||
if telegram_user_id is None:
|
||||
log.warning("initData valid but telegram_user_id missing")
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||
if not user:
|
||||
log.warning(
|
||||
"user not in DB (username=%s, telegram_id=%s)",
|
||||
username,
|
||||
telegram_user_id,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
||||
failed_phone = config.normalize_phone(user.phone) if user.phone else None
|
||||
log.warning(
|
||||
"access denied (username=%s, telegram_id=%s, phone=%s)",
|
||||
username,
|
||||
telegram_user_id,
|
||||
failed_phone or "—",
|
||||
)
|
||||
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||
return telegram_user_id
|
||||
|
||||
|
||||
def get_authenticated_telegram_id_dep(
|
||||
request: Request,
|
||||
x_telegram_init_data: Annotated[
|
||||
str | None, Header(alias="X-Telegram-Init-Data")
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> int:
|
||||
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
|
||||
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
|
||||
|
||||
|
||||
def require_admin_telegram_id(
|
||||
request: Request,
|
||||
x_telegram_init_data: Annotated[
|
||||
str | None, Header(alias="X-Telegram-Init-Data")
|
||||
] = None,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> int:
|
||||
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
|
||||
|
||||
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
|
||||
|
||||
Raises:
|
||||
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
|
||||
"""
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||
telegram_user_id = get_authenticated_telegram_id(
|
||||
request, x_telegram_init_data, session
|
||||
)
|
||||
if not is_admin_for_telegram_user(session, telegram_user_id):
|
||||
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||
return telegram_user_id
|
||||
|
||||
|
||||
def fetch_duties_response(
|
||||
session: Session, from_date: str, to_date: str
|
||||
) -> list[DutyWithUser]:
|
||||
|
||||
@@ -6,7 +6,7 @@ import json
|
||||
import time
|
||||
from urllib.parse import unquote
|
||||
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
import duty_teller.config as config
|
||||
|
||||
# Telegram algorithm: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
# Data-check string: sorted key=value with URL-decoded values, then HMAC-SHA256(WebAppData, token) as secret.
|
||||
@@ -48,12 +48,12 @@ def validate_init_data_with_reason(
|
||||
Returns:
|
||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
||||
"ok", lang).
|
||||
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||
On success: (user.id, username or None, "ok", lang).
|
||||
"""
|
||||
lang = config.DEFAULT_LANGUAGE
|
||||
if not init_data or not bot_token:
|
||||
return (None, None, "empty", "en")
|
||||
return (None, None, "empty", lang)
|
||||
init_data = init_data.strip()
|
||||
params = {}
|
||||
for part in init_data.split("&"):
|
||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
||||
params[key] = value
|
||||
hash_val = params.pop("hash", None)
|
||||
if not hash_val:
|
||||
return (None, None, "no_hash", "en")
|
||||
return (None, None, "no_hash", lang)
|
||||
data_pairs = sorted(params.items())
|
||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
||||
data_string = "\n".join(f"{k}={unquote(v)}" for k, v in data_pairs)
|
||||
@@ -81,27 +81,26 @@ def validate_init_data_with_reason(
|
||||
digestmod=hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
||||
return (None, None, "hash_mismatch", "en")
|
||||
return (None, None, "hash_mismatch", lang)
|
||||
if max_age_seconds is not None and max_age_seconds > 0:
|
||||
auth_date_raw = params.get("auth_date")
|
||||
if not auth_date_raw:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
try:
|
||||
auth_date = int(float(auth_date_raw))
|
||||
except (ValueError, TypeError):
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
if time.time() - auth_date > max_age_seconds:
|
||||
return (None, None, "auth_date_expired", "en")
|
||||
return (None, None, "auth_date_expired", lang)
|
||||
user_raw = params.get("user")
|
||||
if not user_raw:
|
||||
return (None, None, "no_user", "en")
|
||||
return (None, None, "no_user", lang)
|
||||
try:
|
||||
user = json.loads(unquote(user_raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return (None, None, "user_invalid", "en")
|
||||
return (None, None, "user_invalid", lang)
|
||||
if not isinstance(user, dict):
|
||||
return (None, None, "user_invalid", "en")
|
||||
lang = normalize_lang(user.get("language_code"))
|
||||
return (None, None, "user_invalid", lang)
|
||||
raw_id = user.get("id")
|
||||
if raw_id is None:
|
||||
return (None, None, "no_user_id", lang)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Load configuration from environment (e.g. .env via python-dotenv).
|
||||
|
||||
BOT_TOKEN is not validated on import; call require_bot_token() in the entry point
|
||||
when running the bot.
|
||||
when running the bot. Numeric env vars (HTTP_PORT, INIT_DATA_MAX_AGE_SECONDS) use
|
||||
safe parsing with defaults on invalid values.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
@@ -15,6 +17,14 @@ from duty_teller.i18n.lang import normalize_lang
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Valid port range for HTTP_PORT.
|
||||
HTTP_PORT_MIN, HTTP_PORT_MAX = 1, 65535
|
||||
|
||||
# Host values treated as loopback (for health-check URL and MINI_APP_SKIP_AUTH safety).
|
||||
LOOPBACK_HTTP_HOSTS = ("127.0.0.1", "localhost", "::1", "")
|
||||
|
||||
# Project root (parent of duty_teller package). Used for webapp path, etc.
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
@@ -46,6 +56,56 @@ def _parse_phone_list(raw: str) -> set[str]:
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_log_level(raw: str) -> str:
|
||||
"""Return a valid log level name (DEBUG, INFO, WARNING, ERROR); default INFO."""
|
||||
level = (raw or "").strip().upper()
|
||||
if level in ("DEBUG", "INFO", "WARNING", "ERROR"):
|
||||
return level
|
||||
return "INFO"
|
||||
|
||||
|
||||
def _parse_int_env(
|
||||
name: str, default: int, min_val: int | None = None, max_val: int | None = None
|
||||
) -> int:
|
||||
"""Parse an integer from os.environ; use default on invalid or out-of-range. Log on fallback."""
|
||||
raw = os.getenv(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
value = int(raw.strip())
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Invalid %s=%r (expected integer); using default %s",
|
||||
name,
|
||||
raw,
|
||||
default,
|
||||
)
|
||||
return default
|
||||
if min_val is not None and value < min_val:
|
||||
logger.warning(
|
||||
"%s=%s is below minimum %s; using %s", name, value, min_val, min_val
|
||||
)
|
||||
return min_val
|
||||
if max_val is not None and value > max_val:
|
||||
logger.warning(
|
||||
"%s=%s is above maximum %s; using %s", name, value, max_val, max_val
|
||||
)
|
||||
return max_val
|
||||
return value
|
||||
|
||||
|
||||
def _validate_database_url(url: str) -> bool:
|
||||
"""Return True if URL looks like a supported SQLAlchemy URL (sqlite or postgres)."""
|
||||
if not url or not isinstance(url, str):
|
||||
return False
|
||||
u = url.strip().split("?", 1)[0].lower()
|
||||
return (
|
||||
u.startswith("sqlite://")
|
||||
or u.startswith("postgresql://")
|
||||
or u.startswith("postgres://")
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||
@@ -54,6 +114,7 @@ class Settings:
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
mini_app_short_name: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
allowed_usernames: set[str]
|
||||
@@ -67,6 +128,7 @@ class Settings:
|
||||
duty_display_tz: str
|
||||
default_language: str
|
||||
duty_pin_notify: bool
|
||||
log_level: str
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "Settings":
|
||||
@@ -95,20 +157,33 @@ class Settings:
|
||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||
http_host = raw_host if raw_host else "127.0.0.1"
|
||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
||||
database_url = os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db")
|
||||
if not _validate_database_url(database_url):
|
||||
logger.warning(
|
||||
"DATABASE_URL does not look like a supported URL (sqlite:// or postgresql://); "
|
||||
"DB connection may fail."
|
||||
)
|
||||
http_port = _parse_int_env(
|
||||
"HTTP_PORT", 8080, min_val=HTTP_PORT_MIN, max_val=HTTP_PORT_MAX
|
||||
)
|
||||
init_data_max_age = _parse_int_env("INIT_DATA_MAX_AGE_SECONDS", 0, min_val=0)
|
||||
return cls(
|
||||
bot_token=bot_token,
|
||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
||||
database_url=database_url,
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "")
|
||||
.strip()
|
||||
.strip("/"),
|
||||
http_host=http_host,
|
||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
||||
http_port=http_port,
|
||||
allowed_usernames=allowed,
|
||||
admin_usernames=admin,
|
||||
allowed_phones=allowed_phones,
|
||||
admin_phones=admin_phones,
|
||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||
in ("1", "true", "yes"),
|
||||
init_data_max_age_seconds=int(os.getenv("INIT_DATA_MAX_AGE_SECONDS", "0")),
|
||||
init_data_max_age_seconds=init_data_max_age,
|
||||
cors_origins=cors,
|
||||
external_calendar_ics_url=os.getenv(
|
||||
"EXTERNAL_CALENDAR_ICS_URL", ""
|
||||
@@ -118,6 +193,7 @@ class Settings:
|
||||
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
||||
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
|
||||
not in ("0", "false", "no"),
|
||||
log_level=_normalize_log_level(os.getenv("LOG_LEVEL", "INFO")),
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +204,7 @@ BOT_TOKEN = _settings.bot_token
|
||||
DATABASE_URL = _settings.database_url
|
||||
BOT_USERNAME = _settings.bot_username
|
||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||
HTTP_HOST = _settings.http_host
|
||||
HTTP_PORT = _settings.http_port
|
||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
||||
@@ -141,6 +218,8 @@ EXTERNAL_CALENDAR_ICS_URL = _settings.external_calendar_ics_url
|
||||
DUTY_DISPLAY_TZ = _settings.duty_display_tz
|
||||
DEFAULT_LANGUAGE = _settings.default_language
|
||||
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
|
||||
LOG_LEVEL = getattr(logging, _settings.log_level.upper(), logging.INFO)
|
||||
LOG_LEVEL_STR = _settings.log_level
|
||||
|
||||
|
||||
def is_admin(username: str) -> bool:
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES
|
||||
from duty_teller.db.models import (
|
||||
User,
|
||||
Duty,
|
||||
@@ -201,14 +202,19 @@ def get_or_create_user(
|
||||
return user
|
||||
|
||||
|
||||
def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
def get_or_create_user_by_full_name(
|
||||
session: Session, full_name: str, *, commit: bool = True
|
||||
) -> User:
|
||||
"""Find user by exact full_name or create one (for duty-schedule import).
|
||||
|
||||
New users have telegram_user_id=None and name_manually_edited=True.
|
||||
When commit=False, caller is responsible for committing (e.g. single commit
|
||||
per import in run_import).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
full_name: Exact full name to match or set.
|
||||
commit: If True, commit immediately. If False, caller commits.
|
||||
|
||||
Returns:
|
||||
User instance (existing or newly created).
|
||||
@@ -225,8 +231,11 @@ def get_or_create_user_by_full_name(session: Session, full_name: str) -> User:
|
||||
name_manually_edited=True,
|
||||
)
|
||||
session.add(user)
|
||||
if commit:
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
else:
|
||||
session.flush() # Assign id so caller can use user.id before commit
|
||||
return user
|
||||
|
||||
|
||||
@@ -313,6 +322,61 @@ def delete_duties_in_range(
|
||||
return count
|
||||
|
||||
|
||||
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
|
||||
"""Return duty by primary key.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
duty_id: Duty id (duties.id).
|
||||
|
||||
Returns:
|
||||
Duty or None if not found.
|
||||
"""
|
||||
return session.get(Duty, duty_id)
|
||||
|
||||
|
||||
def update_duty_user(
|
||||
session: Session,
|
||||
duty_id: int,
|
||||
new_user_id: int,
|
||||
*,
|
||||
commit: bool = True,
|
||||
) -> Duty | None:
|
||||
"""Update the assigned user of a duty.
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
duty_id: Duty id (duties.id).
|
||||
new_user_id: New user id (users.id).
|
||||
commit: If True, commit immediately. If False, caller commits.
|
||||
|
||||
Returns:
|
||||
Updated Duty or None if duty not found.
|
||||
"""
|
||||
duty = session.get(Duty, duty_id)
|
||||
if duty is None:
|
||||
return None
|
||||
duty.user_id = new_user_id
|
||||
if commit:
|
||||
session.commit()
|
||||
session.refresh(duty)
|
||||
else:
|
||||
session.flush()
|
||||
return duty
|
||||
|
||||
|
||||
def get_users_for_admin(session: Session) -> list[User]:
|
||||
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
|
||||
|
||||
Args:
|
||||
session: DB session.
|
||||
|
||||
Returns:
|
||||
List of User instances ordered by full_name.
|
||||
"""
|
||||
return session.query(User).order_by(User.full_name).all()
|
||||
|
||||
|
||||
def get_duties(
|
||||
session: Session,
|
||||
from_date: str,
|
||||
@@ -447,11 +511,13 @@ def insert_duty(
|
||||
user_id: User id.
|
||||
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
|
||||
end_at: End time UTC, ISO 8601 with Z.
|
||||
event_type: One of "duty", "unavailable", "vacation". Default "duty".
|
||||
event_type: One of "duty", "unavailable", "vacation". Invalid values are stored as "duty".
|
||||
|
||||
Returns:
|
||||
Created Duty instance.
|
||||
"""
|
||||
if event_type not in DUTY_EVENT_TYPES:
|
||||
event_type = "duty"
|
||||
duty = Duty(
|
||||
user_id=user_id,
|
||||
start_at=start_at,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -67,7 +67,8 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
phone = " ".join(args).strip() if args else None
|
||||
telegram_user_id = update.effective_user.id
|
||||
|
||||
def do_set_phone() -> str | None:
|
||||
def do_set_phone() -> tuple[str, str | None]:
|
||||
"""Returns (status, display_phone). status is 'error'|'saved'|'cleared'. display_phone for 'saved'."""
|
||||
with session_scope(config.DATABASE_URL) as session:
|
||||
full_name = build_full_name(
|
||||
update.effective_user.first_name, update.effective_user.last_name
|
||||
@@ -82,16 +83,20 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
)
|
||||
user = set_user_phone(session, telegram_user_id, phone or None)
|
||||
if user is None:
|
||||
return "error"
|
||||
return ("error", None)
|
||||
if phone:
|
||||
return "saved"
|
||||
return "cleared"
|
||||
return ("saved", user.phone or config.normalize_phone(phone))
|
||||
return ("cleared", None)
|
||||
|
||||
result = await asyncio.get_running_loop().run_in_executor(None, do_set_phone)
|
||||
result, display_phone = await asyncio.get_running_loop().run_in_executor(
|
||||
None, do_set_phone
|
||||
)
|
||||
if result == "error":
|
||||
await update.message.reply_text(t(lang, "set_phone.error"))
|
||||
elif result == "saved":
|
||||
await update.message.reply_text(t(lang, "set_phone.saved", phone=phone or ""))
|
||||
await update.message.reply_text(
|
||||
t(lang, "set_phone.saved", phone=display_phone or "")
|
||||
)
|
||||
else:
|
||||
await update.message.reply_text(t(lang, "set_phone.cleared"))
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
import duty_teller.config as config
|
||||
@@ -29,6 +30,10 @@ from duty_teller.services.group_duty_pin_service import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-chat locks to prevent concurrent refresh for the same chat (avoids duplicate messages).
|
||||
_refresh_locks: dict[int, asyncio.Lock] = {}
|
||||
_lock_for_refresh_locks = asyncio.Lock()
|
||||
|
||||
JOB_NAME_PREFIX = "duty_pin_"
|
||||
RETRY_WHEN_NO_DUTY_MINUTES = 15
|
||||
|
||||
@@ -87,9 +92,18 @@ def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||
|
||||
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
|
||||
with start_param=duty (current duty view). Otherwise the link is to the bot with
|
||||
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
|
||||
start_param in some clients).
|
||||
"""
|
||||
if not config.BOT_USERNAME:
|
||||
return None
|
||||
short = (config.MINI_APP_SHORT_NAME or "").strip().strip("/")
|
||||
if short:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}/{short}?startapp=duty"
|
||||
else:
|
||||
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||
button = InlineKeyboardButton(
|
||||
text=t(lang, "pin_duty.view_contacts"),
|
||||
@@ -115,8 +129,12 @@ def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
|
||||
|
||||
|
||||
async def _schedule_next_update(
|
||||
application, chat_id: int, when_utc: datetime | None
|
||||
application,
|
||||
chat_id: int,
|
||||
when_utc: datetime | None,
|
||||
jitter_seconds: float | None = None,
|
||||
) -> None:
|
||||
"""Schedule the next pin refresh job. Optional jitter spreads jobs when scheduling many chats."""
|
||||
job_queue = application.job_queue
|
||||
if job_queue is None:
|
||||
logger.warning("Job queue not available, cannot schedule pin update")
|
||||
@@ -127,8 +145,10 @@ async def _schedule_next_update(
|
||||
if when_utc is not None:
|
||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
delay = when_utc - now_utc
|
||||
if jitter_seconds is not None and jitter_seconds > 0:
|
||||
delay += timedelta(seconds=random.uniform(0, jitter_seconds))
|
||||
if delay.total_seconds() < 1:
|
||||
delay = 1
|
||||
delay = timedelta(seconds=1)
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=delay,
|
||||
@@ -137,8 +157,6 @@ async def _schedule_next_update(
|
||||
)
|
||||
logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc)
|
||||
else:
|
||||
from datetime import timedelta
|
||||
|
||||
job_queue.run_once(
|
||||
update_group_pin,
|
||||
when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES),
|
||||
@@ -159,11 +177,13 @@ async def _refresh_pin_for_chat(
|
||||
|
||||
Uses single DB session for message_id, text, next_shift_end (consolidated).
|
||||
If the group is no longer trusted, removes pin record, job, and message; returns "untrusted".
|
||||
Unpin is best-effort (e.g. if user already unpinned we still pin the new message and save state).
|
||||
Per-chat lock prevents concurrent refresh for the same chat.
|
||||
|
||||
Returns:
|
||||
"updated" if the message was sent, pinned and saved successfully;
|
||||
"no_message" if there is no pin record for this chat;
|
||||
"failed" if send_message or permissions failed;
|
||||
"failed" if send_message or pin failed;
|
||||
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -196,6 +216,11 @@ async def _refresh_pin_for_chat(
|
||||
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
||||
return "no_message"
|
||||
old_message_id = message_id
|
||||
|
||||
async with _lock_for_refresh_locks:
|
||||
lock = _refresh_locks.setdefault(chat_id, asyncio.Lock())
|
||||
try:
|
||||
async with lock:
|
||||
try:
|
||||
msg = await context.bot.send_message(
|
||||
chat_id=chat_id,
|
||||
@@ -204,25 +229,34 @@ async def _refresh_pin_for_chat(
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
|
||||
"Failed to send duty message for pin refresh chat_id=%s: %s",
|
||||
chat_id,
|
||||
e,
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
try:
|
||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.debug(
|
||||
"Unpin failed (e.g. no pinned message) chat_id=%s: %s", chat_id, e
|
||||
)
|
||||
try:
|
||||
await context.bot.pin_chat_message(
|
||||
chat_id=chat_id,
|
||||
message_id=msg.message_id,
|
||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
logger.warning("Pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "failed"
|
||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||
if old_message_id is not None:
|
||||
try:
|
||||
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
|
||||
await context.bot.delete_message(
|
||||
chat_id=chat_id, message_id=old_message_id
|
||||
)
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning(
|
||||
"Could not delete old pinned message %s in chat_id=%s: %s",
|
||||
@@ -232,6 +266,9 @@ async def _refresh_pin_for_chat(
|
||||
)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
return "updated"
|
||||
finally:
|
||||
async with _lock_for_refresh_locks:
|
||||
_refresh_locks.pop(chat_id, None)
|
||||
|
||||
|
||||
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
@@ -329,12 +366,15 @@ def _get_all_pin_chat_ids_sync() -> list[int]:
|
||||
|
||||
|
||||
async def restore_group_pin_jobs(application) -> None:
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup)."""
|
||||
"""Restore scheduled pin-update jobs for all chats that have a pinned message (on startup).
|
||||
|
||||
Uses jitter (0–60 s) per chat to avoid thundering herd when many groups share the same shift end.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
for chat_id in chat_ids:
|
||||
await _schedule_next_update(application, chat_id, next_end)
|
||||
await _schedule_next_update(application, chat_id, next_end, jitter_seconds=60.0)
|
||||
logger.info("Restored %s group pin jobs", len(chat_ids))
|
||||
|
||||
|
||||
@@ -398,6 +438,8 @@ async def pin_duty_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No
|
||||
message_id=message_id,
|
||||
disable_notification=True,
|
||||
)
|
||||
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||
await _schedule_next_update(context.application, chat_id, next_end)
|
||||
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||
except (BadRequest, Forbidden) as e:
|
||||
logger.warning("pin_duty failed chat_id=%s: %s", chat_id, e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Import duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import duty_teller.config as config
|
||||
from telegram import Update
|
||||
@@ -16,6 +17,8 @@ from duty_teller.importers.duty_schedule import (
|
||||
from duty_teller.services.import_service import run_import
|
||||
from duty_teller.utils.handover import parse_handover_time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def import_duty_schedule_cmd(
|
||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
@@ -80,9 +83,10 @@ async def handle_duty_schedule_document(
|
||||
try:
|
||||
result = parse_duty_schedule(raw)
|
||||
except DutyScheduleParseError as e:
|
||||
logger.warning("Duty schedule parse error: %s", e, exc_info=True)
|
||||
context.user_data.pop("awaiting_duty_schedule_file", None)
|
||||
context.user_data.pop("handover_utc_time", None)
|
||||
await update.message.reply_text(t(lang, "import.parse_error", error=str(e)))
|
||||
await update.message.reply_text(t(lang, "import.parse_error_generic"))
|
||||
return
|
||||
|
||||
def run_import_with_scope():
|
||||
@@ -95,7 +99,8 @@ async def handle_duty_schedule_document(
|
||||
None, run_import_with_scope
|
||||
)
|
||||
except Exception as e:
|
||||
await update.message.reply_text(t(lang, "import.import_error", error=str(e)))
|
||||
logger.exception("Import failed: %s", e)
|
||||
await update.message.reply_text(t(lang, "import.import_error_generic"))
|
||||
else:
|
||||
total = num_duty + num_unavailable + num_vacation
|
||||
unavailable_suffix = (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""get_lang and t(): language from Telegram user, translate by key with fallback to en."""
|
||||
"""get_lang and t(): language from config (DEFAULT_LANGUAGE), translate by key with fallback to en."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n.lang import normalize_lang
|
||||
from duty_teller.i18n.messages import MESSAGES
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def get_lang(user: "User | None") -> str:
|
||||
"""
|
||||
Normalize Telegram user language to 'ru' or 'en'.
|
||||
Uses normalize_lang for user.language_code; when user is None or has no
|
||||
language_code, returns config.DEFAULT_LANGUAGE.
|
||||
Return the application language: always config.DEFAULT_LANGUAGE.
|
||||
|
||||
The user argument is kept for backward compatibility but is ignored.
|
||||
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||
"""
|
||||
if user is None or not getattr(user, "language_code", None):
|
||||
return config.DEFAULT_LANGUAGE
|
||||
return normalize_lang(user.language_code)
|
||||
|
||||
|
||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
||||
|
||||
@@ -72,7 +72,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Send the duty-schedule file (JSON).",
|
||||
"import.need_json": "File must have .json extension.",
|
||||
"import.parse_error": "File parse error: {error}",
|
||||
"import.parse_error_generic": "The file could not be parsed. Check the format and try again.",
|
||||
"import.import_error": "Import error: {error}",
|
||||
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
|
||||
"import.done": (
|
||||
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
|
||||
"({total} events total)."
|
||||
@@ -86,8 +88,10 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
),
|
||||
"api.auth_invalid": "Invalid auth data",
|
||||
"api.access_denied": "Access denied",
|
||||
"api.bad_request": "Bad request",
|
||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||
"dates.from_after_to": "from date must not be after to",
|
||||
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"current_duty.title": "Current Duty",
|
||||
@@ -95,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"current_duty.shift": "Shift",
|
||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||
"current_duty.back": "Back to calendar",
|
||||
"admin.duty_not_found": "Duty not found",
|
||||
"admin.user_not_found": "User not found",
|
||||
"admin.reassign_success": "Duty reassigned successfully",
|
||||
},
|
||||
"ru": {
|
||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||
@@ -160,7 +167,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
|
||||
"import.need_json": "Нужен файл с расширением .json",
|
||||
"import.parse_error": "Ошибка разбора файла: {error}",
|
||||
"import.parse_error_generic": "Не удалось разобрать файл. Проверьте формат и попробуйте снова.",
|
||||
"import.import_error": "Ошибка импорта: {error}",
|
||||
"import.import_error_generic": "Импорт не выполнен. Попробуйте снова или обратитесь к администратору.",
|
||||
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
|
||||
"import.done_unavailable": ", {count} недоступностей",
|
||||
"import.done_vacation": ", {count} отпусков",
|
||||
@@ -169,8 +178,10 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"из которого открыт календарь (тот же бот, что в меню).",
|
||||
"api.auth_invalid": "Неверные данные авторизации",
|
||||
"api.access_denied": "Доступ запрещён",
|
||||
"api.bad_request": "Неверный запрос",
|
||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"current_duty.title": "Текущее дежурство",
|
||||
@@ -178,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = {
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||
"current_duty.back": "Назад к календарю",
|
||||
"admin.duty_not_found": "Дежурство не найдено",
|
||||
"admin.user_not_found": "Пользователь не найден",
|
||||
"admin.reassign_success": "Дежурство успешно переназначено",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,11 @@ DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
|
||||
UNAVAILABLE_MARKER = "Н"
|
||||
VACATION_MARKER = "О"
|
||||
|
||||
# Limits to avoid abuse and unreasonable input.
|
||||
MAX_SCHEDULE_ROWS = 500
|
||||
MAX_FULL_NAME_LENGTH = 200
|
||||
MAX_DUTY_STRING_LENGTH = 10000
|
||||
|
||||
|
||||
@dataclass
|
||||
class DutyScheduleEntry:
|
||||
@@ -69,10 +74,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
except ValueError as e:
|
||||
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from e
|
||||
|
||||
# Reject dates outside current year ± 1.
|
||||
today = date.today()
|
||||
min_year = today.year - 1
|
||||
max_year = today.year + 1
|
||||
if not (min_year <= start_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"meta.start_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
schedule = data.get("schedule")
|
||||
if not isinstance(schedule, list):
|
||||
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
||||
|
||||
if len(schedule) > MAX_SCHEDULE_ROWS:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule has too many rows (max {MAX_SCHEDULE_ROWS})"
|
||||
)
|
||||
|
||||
max_days = 0
|
||||
entries: list[DutyScheduleEntry] = []
|
||||
|
||||
@@ -85,12 +104,20 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
full_name = name.strip()
|
||||
if not full_name:
|
||||
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
|
||||
if len(full_name) > MAX_FULL_NAME_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'name' must not exceed {MAX_FULL_NAME_LENGTH} characters"
|
||||
)
|
||||
|
||||
duty_str = row.get("duty")
|
||||
if duty_str is None:
|
||||
duty_str = ""
|
||||
if not isinstance(duty_str, str):
|
||||
raise DutyScheduleParseError("schedule item 'duty' must be string")
|
||||
if len(duty_str) > MAX_DUTY_STRING_LENGTH:
|
||||
raise DutyScheduleParseError(
|
||||
f"schedule item 'duty' must not exceed {MAX_DUTY_STRING_LENGTH} characters"
|
||||
)
|
||||
|
||||
cells = [c.strip() for c in duty_str.split(";")]
|
||||
max_days = max(max_days, len(cells))
|
||||
@@ -120,4 +147,9 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
||||
else:
|
||||
end_date = start_date + timedelta(days=max_days - 1)
|
||||
|
||||
if not (min_year <= end_date.year <= max_year):
|
||||
raise DutyScheduleParseError(
|
||||
f"Computed end_date year must be between {min_year} and {max_year}"
|
||||
)
|
||||
|
||||
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from telegram.ext import ApplicationBuilder
|
||||
@@ -13,6 +15,15 @@ from duty_teller.config import require_bot_token
|
||||
from duty_teller.handlers import group_duty_pin, register_handlers
|
||||
from duty_teller.utils.http_client import safe_urlopen
|
||||
|
||||
# Seconds to wait for HTTP server to bind before health check.
|
||||
_HTTP_STARTUP_WAIT_SEC = 3
|
||||
|
||||
|
||||
async def _post_init(application) -> None:
|
||||
"""Run startup tasks: restore group pin jobs, then resolve bot username."""
|
||||
await group_duty_pin.restore_group_pin_jobs(application)
|
||||
await _resolve_bot_username(application)
|
||||
|
||||
|
||||
async def _resolve_bot_username(application) -> None:
|
||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||
@@ -24,7 +35,7 @@ async def _resolve_bot_username(application) -> None:
|
||||
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=logging.INFO,
|
||||
level=config.LOG_LEVEL,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,32 +80,59 @@ def _run_uvicorn(web_app, port: int) -> None:
|
||||
loop.run_until_complete(server.serve())
|
||||
|
||||
|
||||
def _wait_for_http_ready(port: int) -> bool:
|
||||
"""Return True if /health responds successfully within _HTTP_STARTUP_WAIT_SEC."""
|
||||
host = config.HTTP_HOST
|
||||
if host not in config.LOOPBACK_HTTP_HOSTS:
|
||||
host = "127.0.0.1"
|
||||
url = f"http://{host}:{port}/health"
|
||||
deadline = time.monotonic() + _HTTP_STARTUP_WAIT_SEC
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
req = urllib.request.Request(url)
|
||||
with safe_urlopen(req, timeout=2) as resp:
|
||||
if resp.status == 200:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug("Health check not ready yet: %s", e)
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
|
||||
require_bot_token()
|
||||
# Optional: set bot menu button to open the Miniapp. Uncomment to enable:
|
||||
# _set_default_menu_button_webapp()
|
||||
app = (
|
||||
ApplicationBuilder()
|
||||
.token(config.BOT_TOKEN)
|
||||
.post_init(group_duty_pin.restore_group_pin_jobs)
|
||||
.post_init(_resolve_bot_username)
|
||||
.build()
|
||||
)
|
||||
app = ApplicationBuilder().token(config.BOT_TOKEN).post_init(_post_init).build()
|
||||
register_handlers(app)
|
||||
|
||||
from duty_teller.api.app import app as web_app
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
if config.MINI_APP_SKIP_AUTH:
|
||||
logger.warning(
|
||||
"MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev"
|
||||
)
|
||||
if config.HTTP_HOST not in config.LOOPBACK_HTTP_HOSTS:
|
||||
print(
|
||||
"ERROR: MINI_APP_SKIP_AUTH must not be used in production (non-localhost).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
t = threading.Thread(
|
||||
target=_run_uvicorn,
|
||||
args=(web_app, config.HTTP_PORT),
|
||||
daemon=False,
|
||||
)
|
||||
t.start()
|
||||
|
||||
if not _wait_for_http_ready(config.HTTP_PORT):
|
||||
logger.error(
|
||||
"HTTP server did not become ready on port %s within %s s; check port and permissions.",
|
||||
config.HTTP_PORT,
|
||||
_HTTP_STARTUP_WAIT_SEC,
|
||||
)
|
||||
sys.exit(1)
|
||||
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||
app.run_polling(allowed_updates=["message", "my_chat_member"])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Import duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -14,6 +15,8 @@ from duty_teller.db.repository import (
|
||||
from duty_teller.importers.duty_schedule import DutyScheduleResult
|
||||
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
||||
@@ -53,16 +56,24 @@ def run_import(
|
||||
Returns:
|
||||
Tuple (num_users, num_duty, num_unavailable, num_vacation).
|
||||
"""
|
||||
logger.info(
|
||||
"Import started: range %s..%s, %d entries",
|
||||
result.start_date,
|
||||
result.end_date,
|
||||
len(result.entries),
|
||||
)
|
||||
from_date_str = result.start_date.isoformat()
|
||||
to_date_str = result.end_date.isoformat()
|
||||
num_duty = num_unavailable = num_vacation = 0
|
||||
|
||||
# Batch: get all users by full_name, create missing
|
||||
# Batch: get all users by full_name, create missing (no commit until end)
|
||||
names = [e.full_name for e in result.entries]
|
||||
users_map = get_users_by_full_names(session, names)
|
||||
for name in names:
|
||||
if name not in users_map:
|
||||
users_map[name] = get_or_create_user_by_full_name(session, name)
|
||||
users_map[name] = get_or_create_user_by_full_name(
|
||||
session, name, commit=False
|
||||
)
|
||||
|
||||
# Delete range per user (no commit)
|
||||
for entry in result.entries:
|
||||
@@ -113,4 +124,11 @@ def run_import(
|
||||
session.bulk_insert_mappings(Duty, duty_rows)
|
||||
session.commit()
|
||||
invalidate_duty_related_caches()
|
||||
logger.info(
|
||||
"Import done: %d users, %d duty, %d unavailable, %d vacation",
|
||||
len(result.entries),
|
||||
num_duty,
|
||||
num_unavailable,
|
||||
num_vacation,
|
||||
)
|
||||
return (len(result.entries), num_duty, num_unavailable, num_vacation)
|
||||
|
||||
@@ -24,10 +24,17 @@ def duty_to_iso(d: date, hour_utc: int, minute_utc: int) -> str:
|
||||
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||
|
||||
|
||||
# Maximum allowed date range in days (e.g. 731 = 2 years).
|
||||
MAX_DATE_RANGE_DAYS = 731
|
||||
|
||||
|
||||
class DateRangeValidationError(ValueError):
|
||||
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
|
||||
|
||||
def __init__(self, kind: Literal["bad_format", "from_after_to"]) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
kind: Literal["bad_format", "from_after_to", "range_too_large"],
|
||||
) -> None:
|
||||
self.kind = kind
|
||||
super().__init__(kind)
|
||||
|
||||
@@ -86,12 +93,20 @@ def parse_iso_date(s: str) -> date | None:
|
||||
|
||||
|
||||
def validate_date_range(from_date: str, to_date: str) -> None:
|
||||
"""Validate from_date and to_date are YYYY-MM-DD and from_date <= to_date.
|
||||
"""Validate from_date and to_date are YYYY-MM-DD, from_date <= to_date, and range <= MAX_DATE_RANGE_DAYS.
|
||||
|
||||
Raises:
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to.
|
||||
DateRangeValidationError: bad_format if format invalid, from_after_to if from > to,
|
||||
range_too_large if (to_date - from_date) > MAX_DATE_RANGE_DAYS.
|
||||
"""
|
||||
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
||||
raise DateRangeValidationError("bad_format")
|
||||
if from_date > to_date:
|
||||
raise DateRangeValidationError("from_after_to")
|
||||
try:
|
||||
from_d = date.fromisoformat(from_date)
|
||||
to_d = date.fromisoformat(to_date)
|
||||
except ValueError:
|
||||
raise DateRangeValidationError("bad_format") from None
|
||||
if (to_d - from_d).days > MAX_DATE_RANGE_DAYS:
|
||||
raise DateRangeValidationError("range_too_large")
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "duty-teller"
|
||||
version = "0.1.0"
|
||||
version = "2.1.3"
|
||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
396
tests/test_admin_api.py
Normal file
396
tests/test_admin_api.py
Normal 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"] == "Дежурство не найдено"
|
||||
@@ -10,24 +10,29 @@ import duty_teller.config as config
|
||||
|
||||
|
||||
class TestLangFromAcceptLanguage:
|
||||
"""Tests for _lang_from_accept_language."""
|
||||
"""Tests for _lang_from_accept_language: always returns config.DEFAULT_LANGUAGE."""
|
||||
|
||||
def test_none_returns_default(self):
|
||||
def test_always_returns_default_language(self):
|
||||
"""Header is ignored; result is always config.DEFAULT_LANGUAGE."""
|
||||
assert deps._lang_from_accept_language(None) == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
assert deps._lang_from_accept_language("") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language(" ") == config.DEFAULT_LANGUAGE
|
||||
assert (
|
||||
deps._lang_from_accept_language("ru-RU,ru;q=0.9") == config.DEFAULT_LANGUAGE
|
||||
)
|
||||
assert deps._lang_from_accept_language("en-US") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("zz") == config.DEFAULT_LANGUAGE
|
||||
assert deps._lang_from_accept_language("x") == config.DEFAULT_LANGUAGE
|
||||
|
||||
def test_ru_ru_returns_ru(self):
|
||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
||||
def test_returns_ru_when_default_language_is_ru(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "ru"):
|
||||
assert deps._lang_from_accept_language("en-US") == "ru"
|
||||
assert deps._lang_from_accept_language(None) == "ru"
|
||||
|
||||
def test_en_us_returns_en(self):
|
||||
assert deps._lang_from_accept_language("en-US") == "en"
|
||||
|
||||
def test_invalid_fallback_to_en(self):
|
||||
assert deps._lang_from_accept_language("zz") == "en"
|
||||
assert deps._lang_from_accept_language("x") == "en"
|
||||
def test_returns_en_when_default_language_is_en(self):
|
||||
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
|
||||
assert deps._lang_from_accept_language("ru-RU") == "en"
|
||||
assert deps._lang_from_accept_language(None) == "en"
|
||||
|
||||
|
||||
class TestAuthErrorDetail:
|
||||
|
||||
@@ -23,6 +23,80 @@ def test_health(client):
|
||||
assert r.json() == {"status": "ok"}
|
||||
|
||||
|
||||
def test_unhandled_exception_returns_500_json(client):
|
||||
"""Global exception handler returns 500 JSON without leaking exception details."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from duty_teller.api.app import global_exception_handler
|
||||
|
||||
# Call the registered handler directly: it returns JSON and does not expose str(exc).
|
||||
request = MagicMock()
|
||||
exc = RuntimeError("internal failure")
|
||||
response = global_exception_handler(request, exc)
|
||||
assert response.status_code == 500
|
||||
assert response.body.decode() == '{"detail":"Internal server error"}'
|
||||
assert "internal failure" not in response.body.decode()
|
||||
|
||||
|
||||
def test_health_has_vary_accept_language(client):
|
||||
"""NoCacheStaticMiddleware adds Vary: Accept-Language to all responses."""
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_static_has_no_store_and_vary(client):
|
||||
"""Static files under /app get Cache-Control: no-store and Vary: Accept-Language."""
|
||||
r = client.get("/app/")
|
||||
if r.status_code != 200:
|
||||
r = client.get("/app")
|
||||
assert r.status_code == 200, (
|
||||
"webapp static mount should serve index at /app or /app/"
|
||||
)
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
assert "accept-language" in r.headers.get("vary", "").lower()
|
||||
|
||||
|
||||
def test_app_js_has_no_store(client):
|
||||
"""JS and all static under /app get Cache-Control: no-store."""
|
||||
webapp_out = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||
if not webapp_out.is_dir():
|
||||
pytest.skip("webapp-next/out not built")
|
||||
# Next.js static export serves JS under _next/static/chunks/<hash>.js
|
||||
js_files = list(webapp_out.glob("_next/static/chunks/*.js"))
|
||||
if not js_files:
|
||||
pytest.skip("no JS chunks in webapp-next/out")
|
||||
rel = js_files[0].relative_to(webapp_out)
|
||||
r = client.get(f"/app/{rel.as_posix()}")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
|
||||
|
||||
def test_app_config_js_returns_lang_from_default_language(client):
|
||||
"""GET /app/config.js returns JS setting window.__DT_LANG from config.DEFAULT_LANGUAGE."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("content-type", "").startswith("application/javascript")
|
||||
assert r.headers.get("cache-control") == "no-store"
|
||||
body = r.text
|
||||
assert "window.__DT_LANG" in body
|
||||
assert config.DEFAULT_LANGUAGE in body
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.DEFAULT_LANGUAGE", '"; alert(1); "')
|
||||
@patch("duty_teller.api.app.config.LOG_LEVEL_STR", "DEBUG\x00INJECT")
|
||||
def test_app_config_js_sanitizes_lang_and_log_level(client):
|
||||
"""config.js uses whitelist: invalid lang/log_level produce safe defaults, no script injection."""
|
||||
r = client.get("/app/config.js")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
# Must be valid JS and not contain the raw malicious strings.
|
||||
assert 'window.__DT_LANG = "en"' in body or 'window.__DT_LANG = "ru"' in body
|
||||
assert "alert" not in body
|
||||
assert "INJECT" not in body
|
||||
assert "window.__DT_LOG_LEVEL" in body
|
||||
|
||||
|
||||
def test_duties_invalid_date_format(client):
|
||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||
assert r.status_code == 400
|
||||
@@ -37,6 +111,30 @@ def test_duties_from_after_to(client):
|
||||
assert "from" in detail or "to" in detail or "after" in detail or "позже" in detail
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.config.MINI_APP_SKIP_AUTH", True)
|
||||
def test_duties_range_too_large_400(client):
|
||||
"""Date range longer than MAX_DATE_RANGE_DAYS returns 400 with dates.range_too_large message."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
from_d = date(2020, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
r = client.get(
|
||||
"/api/duties",
|
||||
params={"from": from_d.isoformat(), "to": to_d.isoformat()},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
detail = r.json()["detail"]
|
||||
# EN: "Date range is too large. Request a shorter period." / RU: "Диапазон дат слишком большой..."
|
||||
assert (
|
||||
"range" in detail.lower()
|
||||
or "short" in detail.lower()
|
||||
or "короткий" in detail
|
||||
or "большой" in detail
|
||||
)
|
||||
|
||||
|
||||
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||
def test_duties_403_without_init_data(client):
|
||||
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any client)."""
|
||||
@@ -272,20 +370,21 @@ def test_duties_200_with_unknown_event_type_mapped_to_duty(client):
|
||||
|
||||
|
||||
def test_calendar_ical_team_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 without DB."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/team/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.headers.get("content-type", "").startswith("application/json")
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/team/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
valid_format_token = "B" * 43
|
||||
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
@@ -337,11 +436,10 @@ def test_calendar_ical_team_200_only_duty_and_description(
|
||||
|
||||
|
||||
def test_calendar_ical_404_invalid_token_format(client):
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
|
||||
# Token format must be base64url, 40–50 chars; short or invalid chars → 404
|
||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
|
||||
r = client.get("/api/calendar/ical/short.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
|
||||
assert r2.status_code == 404
|
||||
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
|
||||
@@ -350,13 +448,12 @@ def test_calendar_ical_404_invalid_token_format(client):
|
||||
|
||||
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404."""
|
||||
"""GET /api/calendar/ical/{token}.ics with unknown token returns 404 JSON."""
|
||||
mock_get_user.return_value = None
|
||||
# Use a token that passes format validation (base64url, 40–50 chars)
|
||||
valid_format_token = "A" * 43
|
||||
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
||||
assert r.status_code == 404
|
||||
assert "not found" in r.text.lower()
|
||||
assert r.json() == {"detail": "Not found"}
|
||||
mock_get_user.assert_called_once()
|
||||
|
||||
|
||||
|
||||
@@ -91,3 +91,29 @@ def test_require_bot_token_does_not_raise_when_set(monkeypatch):
|
||||
"""require_bot_token() does nothing when BOT_TOKEN is set."""
|
||||
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
||||
config.require_bot_token()
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_http_port_uses_default(monkeypatch):
|
||||
"""Invalid HTTP_PORT (non-numeric or out of range) yields default or clamped value."""
|
||||
monkeypatch.delenv("HTTP_PORT", raising=False)
|
||||
settings = config.Settings.from_env()
|
||||
assert 1 <= settings.http_port <= 65535
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "not-a-number")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 8080
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "0")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 1
|
||||
|
||||
monkeypatch.setenv("HTTP_PORT", "99999")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.http_port == 65535
|
||||
|
||||
|
||||
def test_settings_from_env_invalid_init_data_max_age_uses_default(monkeypatch):
|
||||
"""Invalid INIT_DATA_MAX_AGE_SECONDS yields default 0."""
|
||||
monkeypatch.setenv("INIT_DATA_MAX_AGE_SECONDS", "invalid")
|
||||
settings = config.Settings.from_env()
|
||||
assert settings.init_data_max_age_seconds == 0
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Tests for duty-schedule JSON parser."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from duty_teller.importers.duty_schedule import (
|
||||
DUTY_MARKERS,
|
||||
MAX_FULL_NAME_LENGTH,
|
||||
MAX_SCHEDULE_ROWS,
|
||||
UNAVAILABLE_MARKER,
|
||||
VACATION_MARKER,
|
||||
DutyScheduleParseError,
|
||||
@@ -118,3 +121,38 @@ def test_unavailable_and_vacation_markers():
|
||||
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
||||
assert entry.vacation_dates == [date(2026, 2, 2)]
|
||||
assert entry.duty_dates == [date(2026, 2, 3)]
|
||||
|
||||
|
||||
def test_parse_start_date_year_out_of_range():
|
||||
"""start_date year must be current ± 1; otherwise DutyScheduleParseError."""
|
||||
# Use a year far in the past/future so it fails regardless of test run date.
|
||||
raw_future = b'{"meta": {"start_date": "2030-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2030"):
|
||||
parse_duty_schedule(raw_future)
|
||||
raw_past = b'{"meta": {"start_date": "2019-01-01"}, "schedule": [{"name": "A", "duty": ""}]}'
|
||||
with pytest.raises(DutyScheduleParseError, match="year|2019"):
|
||||
parse_duty_schedule(raw_past)
|
||||
|
||||
|
||||
def test_parse_too_many_schedule_rows():
|
||||
"""More than MAX_SCHEDULE_ROWS rows raises DutyScheduleParseError."""
|
||||
rows = [{"name": f"User {i}", "duty": ""} for i in range(MAX_SCHEDULE_ROWS + 1)]
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
payload = {"meta": {"start_date": start.isoformat()}, "schedule": rows}
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="too many|max"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
|
||||
def test_parse_full_name_too_long():
|
||||
"""full_name longer than MAX_FULL_NAME_LENGTH raises DutyScheduleParseError."""
|
||||
long_name = "A" * (MAX_FULL_NAME_LENGTH + 1)
|
||||
today = date.today()
|
||||
start = today.replace(month=1, day=1)
|
||||
raw = (
|
||||
f'{{"meta": {{"start_date": "{start.isoformat()}"}}, '
|
||||
f'"schedule": [{{"name": "{long_name}", "duty": ""}}]}}'
|
||||
).encode("utf-8")
|
||||
with pytest.raises(DutyScheduleParseError, match="exceed|character"):
|
||||
parse_duty_schedule(raw)
|
||||
|
||||
@@ -90,10 +90,13 @@ def test_get_contact_button_markup_empty_username_returns_none():
|
||||
|
||||
|
||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
|
||||
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", ""),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
@@ -107,6 +110,22 @@ def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
||||
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with (
|
||||
patch.object(config, "BOT_USERNAME", "MyDutyBot"),
|
||||
patch.object(config, "MINI_APP_SHORT_NAME", "DutyApp"),
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
@@ -277,8 +296,8 @@ async def test_update_group_pin_send_raises_no_unpin_pin_schedule_still_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
|
||||
"""update_group_pin: send_message ok, unpin raises (e.g. no pinned message), pin succeeds -> save_pin and schedule called."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
@@ -287,9 +306,45 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock(
|
||||
side_effect=Forbidden("Not enough rights")
|
||||
side_effect=BadRequest("Chat has no pinned message")
|
||||
)
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.bot.delete_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(
|
||||
mod, "_sync_get_pin_refresh_data", return_value=(3, "Text", None)
|
||||
):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch.object(mod, "_sync_save_pin") as mock_save:
|
||||
await mod.update_group_pin(context)
|
||||
context.bot.send_message.assert_called_once_with(
|
||||
chat_id=222, text="Text", reply_markup=None
|
||||
)
|
||||
context.bot.unpin_chat_message.assert_called_once_with(chat_id=222)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=222, message_id=888, disable_notification=False
|
||||
)
|
||||
mock_save.assert_called_once_with(222, 888)
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_group_pin_pin_raises_no_save_still_schedules_next():
|
||||
"""update_group_pin: send_message ok, unpin ok, pin raises -> no _sync_save_pin, schedule still called, log."""
|
||||
new_msg = MagicMock()
|
||||
new_msg.message_id = 888
|
||||
context = MagicMock()
|
||||
context.job = MagicMock()
|
||||
context.job.data = {"chat_id": 222}
|
||||
context.bot = MagicMock()
|
||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||
context.bot.unpin_chat_message = AsyncMock()
|
||||
context.bot.pin_chat_message = AsyncMock(side_effect=Forbidden("Not enough rights"))
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
||||
@@ -308,7 +363,7 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
||||
)
|
||||
mock_save.assert_not_called()
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "Unpin or pin" in mock_logger.warning.call_args[0][0]
|
||||
assert "Pin after refresh failed" in mock_logger.warning.call_args[0][0]
|
||||
mock_schedule.assert_called_once_with(context.application, 222, None)
|
||||
|
||||
|
||||
@@ -371,7 +426,7 @@ async def test_pin_duty_cmd_group_only_reply():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
"""pin_duty_cmd in group with existing pin record -> pin and reply pinned."""
|
||||
"""pin_duty_cmd in group with existing pin record -> pin, schedule next update, reply pinned."""
|
||||
update = MagicMock()
|
||||
update.message = MagicMock()
|
||||
update.message.reply_text = AsyncMock()
|
||||
@@ -382,16 +437,22 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
||||
context = MagicMock()
|
||||
context.bot = MagicMock()
|
||||
context.bot.pin_chat_message = AsyncMock()
|
||||
context.application = MagicMock()
|
||||
|
||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
||||
with patch.object(mod, "_sync_is_trusted", return_value=True):
|
||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||
with patch.object(
|
||||
mod, "_schedule_next_update", AsyncMock()
|
||||
) as mock_schedule:
|
||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
||||
mock_t.return_value = "Pinned"
|
||||
await mod.pin_duty_cmd(update, context)
|
||||
context.bot.pin_chat_message.assert_called_once_with(
|
||||
chat_id=100, message_id=5, disable_notification=True
|
||||
)
|
||||
mock_schedule.assert_called_once_with(context.application, 100, None)
|
||||
update.message.reply_text.assert_called_once_with("Pinned")
|
||||
|
||||
|
||||
@@ -891,8 +952,8 @@ async def test_my_chat_member_handler_bot_removed_deletes_pin_and_jobs():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
|
||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
|
||||
"""restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
|
||||
application = MagicMock()
|
||||
application.job_queue = MagicMock()
|
||||
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
||||
@@ -905,8 +966,8 @@ async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
||||
) as mock_schedule:
|
||||
await mod.restore_group_pin_jobs(application)
|
||||
assert mock_schedule.call_count == 2
|
||||
mock_schedule.assert_any_call(application, 10, None)
|
||||
mock_schedule.assert_any_call(application, 20, None)
|
||||
mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
|
||||
mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)
|
||||
|
||||
|
||||
# --- _refresh_pin_for_chat untrusted ---
|
||||
|
||||
@@ -278,8 +278,8 @@ async def test_handle_duty_schedule_document_non_json_replies_need_json():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: parse_duty_schedule raises DutyScheduleParseError -> reply, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -299,17 +299,17 @@ async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user
|
||||
with patch.object(mod, "parse_duty_schedule") as mock_parse:
|
||||
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Parse error: Bad JSON"
|
||||
mock_t.return_value = "The file could not be parsed."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Parse error: Bad JSON")
|
||||
mock_t.assert_called_with("en", "import.parse_error", error="Bad JSON")
|
||||
message.reply_text.assert_called_once_with("The file could not be parsed.")
|
||||
mock_t.assert_called_with("en", "import.parse_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_duty_schedule_document_import_error_replies_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import in executor raises -> reply import_error, clear user_data."""
|
||||
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
|
||||
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
|
||||
message = MagicMock()
|
||||
message.document = _make_document()
|
||||
message.reply_text = AsyncMock()
|
||||
@@ -340,10 +340,10 @@ async def test_handle_duty_schedule_document_import_error_replies_and_clears_use
|
||||
with patch.object(mod, "run_import") as mock_run:
|
||||
mock_run.side_effect = ValueError("DB error")
|
||||
with patch.object(mod, "t") as mock_t:
|
||||
mock_t.return_value = "Import error: DB error"
|
||||
mock_t.return_value = "Import failed. Please try again."
|
||||
await mod.handle_duty_schedule_document(update, context)
|
||||
message.reply_text.assert_called_once_with("Import error: DB error")
|
||||
mock_t.assert_called_with("en", "import.import_error", error="DB error")
|
||||
message.reply_text.assert_called_once_with("Import failed. Please try again.")
|
||||
mock_t.assert_called_with("en", "import.import_error_generic")
|
||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||
assert "handover_utc_time" not in context.user_data
|
||||
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
"""Unit tests for duty_teller.i18n: get_lang, t, fallback to en."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.i18n import get_lang, t
|
||||
|
||||
|
||||
def test_get_lang_none_returns_en():
|
||||
assert get_lang(None) == "en"
|
||||
def test_get_lang_always_returns_default_language():
|
||||
"""get_lang ignores user and always returns config.DEFAULT_LANGUAGE."""
|
||||
assert get_lang(None) == config.DEFAULT_LANGUAGE
|
||||
user_ru = MagicMock()
|
||||
user_ru.language_code = "ru"
|
||||
assert get_lang(user_ru) == config.DEFAULT_LANGUAGE
|
||||
user_en = MagicMock()
|
||||
user_en.language_code = "en"
|
||||
assert get_lang(user_en) == config.DEFAULT_LANGUAGE
|
||||
user_any = MagicMock(spec=[])
|
||||
assert get_lang(user_any) == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_get_lang_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru"
|
||||
assert get_lang(user) == "ru"
|
||||
def test_get_lang_returns_ru_when_default_language_is_ru():
|
||||
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "ru"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
|
||||
def test_get_lang_ru_ru_returns_ru():
|
||||
user = MagicMock()
|
||||
user.language_code = "ru-RU"
|
||||
assert get_lang(user) == "ru"
|
||||
|
||||
|
||||
def test_get_lang_en_returns_en():
|
||||
assert core_get_lang(None) == "ru"
|
||||
user = MagicMock()
|
||||
user.language_code = "en"
|
||||
assert get_lang(user) == "en"
|
||||
assert core_get_lang(user) == "ru"
|
||||
|
||||
|
||||
def test_get_lang_uk_returns_en():
|
||||
def test_get_lang_returns_en_when_default_language_is_en():
|
||||
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
|
||||
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||
mock_cfg.DEFAULT_LANGUAGE = "en"
|
||||
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||
|
||||
assert core_get_lang(None) == "en"
|
||||
user = MagicMock()
|
||||
user.language_code = "uk"
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_empty_returns_en():
|
||||
user = MagicMock()
|
||||
user.language_code = ""
|
||||
assert get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_get_lang_missing_attr_returns_en():
|
||||
user = MagicMock(spec=[]) # no language_code
|
||||
assert get_lang(user) == "en"
|
||||
user.language_code = "ru"
|
||||
assert core_get_lang(user) == "en"
|
||||
|
||||
|
||||
def test_t_en_start_greeting():
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -24,10 +24,19 @@ def test_main_builds_app_and_starts_thread():
|
||||
mock_scope.return_value.__exit__.return_value = None
|
||||
|
||||
with patch("duty_teller.run.require_bot_token"):
|
||||
with patch("duty_teller.run.config") as mock_cfg:
|
||||
mock_cfg.MINI_APP_SKIP_AUTH = False
|
||||
mock_cfg.HTTP_HOST = "127.0.0.1"
|
||||
mock_cfg.HTTP_PORT = 8080
|
||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||
with patch("duty_teller.db.session.session_scope", mock_scope):
|
||||
with patch(
|
||||
"duty_teller.run._wait_for_http_ready", return_value=True
|
||||
):
|
||||
with patch(
|
||||
"duty_teller.db.session.session_scope", mock_scope
|
||||
):
|
||||
mock_thread = MagicMock()
|
||||
mock_thread_class.return_value = mock_thread
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import duty_teller.config as config
|
||||
from duty_teller.api.telegram_auth import (
|
||||
validate_init_data,
|
||||
validate_init_data_with_reason,
|
||||
@@ -52,7 +55,7 @@ def test_user_without_username_returns_none_from_validate_init_data():
|
||||
|
||||
|
||||
def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; username may be None."""
|
||||
"""With validate_init_data_with_reason, valid user.id is enough; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -62,11 +65,11 @@ def test_user_without_username_but_with_id_succeeds_with_reason():
|
||||
assert telegram_user_id == 456
|
||||
assert username is None
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_user_without_id_returns_no_user_id():
|
||||
"""When user object exists but has no 'id', return no_user_id."""
|
||||
"""When user object exists but has no 'id', return no_user_id; lang is DEFAULT_LANGUAGE."""
|
||||
bot_token = "123:ABC"
|
||||
user = {"first_name": "Test"} # no id
|
||||
init_data = make_init_data(user, bot_token)
|
||||
@@ -76,7 +79,17 @@ def test_user_without_id_returns_no_user_id():
|
||||
assert telegram_user_id is None
|
||||
assert username is None
|
||||
assert reason == "no_user_id"
|
||||
assert lang == "en"
|
||||
assert lang == config.DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
def test_validate_init_data_with_reason_returns_default_language_ignoring_user_lang():
|
||||
"""Returned lang is always config.DEFAULT_LANGUAGE, not user.language_code."""
|
||||
with patch("duty_teller.api.telegram_auth.config.DEFAULT_LANGUAGE", "ru"):
|
||||
user = {"id": 1, "first_name": "U", "language_code": "en"}
|
||||
init_data = make_init_data(user, "123:ABC")
|
||||
_, _, reason, lang = validate_init_data_with_reason(init_data, "123:ABC")
|
||||
assert reason == "ok"
|
||||
assert lang == "ru"
|
||||
|
||||
|
||||
def test_empty_init_data_returns_none():
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Unit tests for utils (dates, user, handover)."""
|
||||
|
||||
from datetime import date
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -62,6 +62,23 @@ def test_validate_date_range_from_after_to():
|
||||
assert exc_info.value.kind == "from_after_to"
|
||||
|
||||
|
||||
def test_validate_date_range_too_large():
|
||||
"""Range longer than MAX_DATE_RANGE_DAYS raises range_too_large."""
|
||||
from duty_teller.utils.dates import MAX_DATE_RANGE_DAYS
|
||||
|
||||
# from 2023-01-01 to 2025-06-01 is more than 731 days
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range("2023-01-01", "2025-06-01")
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
# Exactly MAX_DATE_RANGE_DAYS + 1 day
|
||||
from_d = date(2024, 1, 1)
|
||||
to_d = from_d + timedelta(days=MAX_DATE_RANGE_DAYS + 1)
|
||||
with pytest.raises(DateRangeValidationError) as exc_info:
|
||||
validate_date_range(from_d.isoformat(), to_d.isoformat())
|
||||
assert exc_info.value.kind == "range_too_large"
|
||||
|
||||
|
||||
# --- user ---
|
||||
|
||||
|
||||
|
||||
41
webapp-next/.gitignore
vendored
Normal file
41
webapp-next/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
webapp-next/README.md
Normal file
36
webapp-next/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
webapp-next/components.json
Normal file
23
webapp-next/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
webapp-next/eslint.config.mjs
Normal file
18
webapp-next/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
10
webapp-next/next.config.ts
Normal file
10
webapp-next/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
basePath: "/app",
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
13495
webapp-next/package-lock.json
generated
Normal file
13495
webapp-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
webapp-next/package.json
Normal file
45
webapp-next/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "webapp-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
7
webapp-next/postcss.config.mjs
Normal file
7
webapp-next/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
244
webapp-next/src/app/admin/page.test.tsx
Normal file
244
webapp-next/src/app/admin/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
131
webapp-next/src/app/admin/page.tsx
Normal file
131
webapp-next/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
BIN
webapp-next/src/app/favicon.ico
Normal file
BIN
webapp-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
55
webapp-next/src/app/global-error.tsx
Normal file
55
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import "./globals.css";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const lang = getLang();
|
||||
|
||||
useEffect(() => {
|
||||
callMiniAppReadyOnce();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang === "ru" ? "ru" : "en"}
|
||||
data-theme="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent className="items-center justify-center gap-4 px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{translate(lang, "error_boundary.message")}
|
||||
</h1>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{translate(lang, "error_boundary.description")}
|
||||
</p>
|
||||
<Button type="button" onClick={() => reset()}>
|
||||
{translate(lang, "error_boundary.reload")}
|
||||
</Button>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
338
webapp-next/src/app/globals.css
Normal file
338
webapp-next/src/app/globals.css
Normal file
@@ -0,0 +1,338 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: system-ui, -apple-system, sans-serif;
|
||||
--font-mono: ui-monospace, monospace;
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
/* App design tokens (Telegram Mini App) */
|
||||
--color-surface: var(--surface);
|
||||
--color-duty: var(--duty);
|
||||
--color-today: var(--today);
|
||||
--color-unavailable: var(--unavailable);
|
||||
--color-vacation: var(--vacation);
|
||||
--color-error: var(--error);
|
||||
--max-width-app: 420px;
|
||||
}
|
||||
|
||||
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
|
||||
:root {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, var(--surface));
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, var(--accent));
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
--timeline-date-width: 3.6em;
|
||||
--timeline-track-width: 10px;
|
||||
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
|
||||
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
|
||||
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
|
||||
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
|
||||
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||
--today-holiday-outline: color-mix(in srgb, var(--bg) 28%, var(--today));
|
||||
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
|
||||
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
|
||||
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
|
||||
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
|
||||
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||
--transition-fast: 0.15s;
|
||||
--transition-normal: 0.25s;
|
||||
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--radius: 0.625rem;
|
||||
--calendar-block-min-height: 260px;
|
||||
/** Safe-area insets for sticky headers and full-screen (Telegram viewport + env fallbacks). */
|
||||
--app-safe-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||
--app-safe-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||
--app-safe-left: var(--tg-viewport-content-safe-area-inset-left, env(safe-area-inset-left, 0));
|
||||
--app-safe-right: var(--tg-viewport-content-safe-area-inset-right, env(safe-area-inset-right, 0));
|
||||
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||
--calendar-grid-min-height: 264px;
|
||||
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||
--calendar-row-min-height: 2.75rem;
|
||||
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
|
||||
--background: var(--bg);
|
||||
--foreground: var(--text);
|
||||
--card-foreground: var(--text);
|
||||
--popover: var(--surface);
|
||||
--popover-foreground: var(--text);
|
||||
--secondary: var(--surface);
|
||||
--secondary-foreground: var(--text);
|
||||
--muted-foreground: var(--muted);
|
||||
--accent-foreground: var(--bg);
|
||||
--destructive: var(--error);
|
||||
--input: color-mix(in srgb, var(--text) 15%, transparent);
|
||||
--ring: var(--accent);
|
||||
--chart-1: var(--duty);
|
||||
--chart-2: var(--vacation);
|
||||
--chart-3: var(--unavailable);
|
||||
--chart-4: var(--today);
|
||||
--chart-5: var(--accent);
|
||||
}
|
||||
|
||||
/* Light theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="light"] {
|
||||
--bg: var(--tg-theme-bg-color, #f0f1f3);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
|
||||
--text: var(--tg-theme-text-color, #343b58);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #6b7089));
|
||||
--accent: var(--tg-theme-link-color, #2e7de0);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
|
||||
--card: var(--tg-theme-section-bg-color, #e0e2e6);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #343b58);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #2e7de0);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
|
||||
--error: var(--tg-theme-destructive-text-color, #c43b3b);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
|
||||
--duty: #587d0a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #0d6b9e;
|
||||
}
|
||||
|
||||
/* Dark theme: full Telegram themeParams (14 params) mapping */
|
||||
[data-theme="dark"] {
|
||||
--bg: var(--tg-theme-bg-color, #17212b);
|
||||
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||
--card: var(--tg-theme-section-bg-color, #232e3c);
|
||||
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||
--primary: var(--tg-theme-button-color, #6ab3f3);
|
||||
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||
--duty: #5c9b4a;
|
||||
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||
--unavailable: #b8860b;
|
||||
--vacation: #5a9bb8;
|
||||
}
|
||||
|
||||
/* === Layout & base (ported from webapp/css/base.css) */
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
|
||||
.container-app {
|
||||
max-width: var(--max-width-app, 420px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 12px;
|
||||
padding-top: 0;
|
||||
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 4px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 2px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--muted-fade) 0%,
|
||||
var(--muted-fade) 50%,
|
||||
var(--muted) 70%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.duty-timeline-date:not(.duty-timeline-date--today)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 6px;
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
/* Duty list: timeline date cell (today) — horizontal stripe + vertical tick in today color (same geometry as non-today) */
|
||||
.duty-timeline-date--today::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 5px;
|
||||
width: calc(100% + var(--timeline-track-width) / 2);
|
||||
height: 1px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
.duty-timeline-date--today::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||
bottom: 2px;
|
||||
width: 2px;
|
||||
height: 7px;
|
||||
background: var(--today);
|
||||
}
|
||||
|
||||
/* Duty list: current duty card — ensure left stripe uses --today (matches "Today" label and date stripe) */
|
||||
[data-current-duty] .border-l-today {
|
||||
border-left-color: var(--today);
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
/* Duty list: flip card (front = duty info, back = contacts) */
|
||||
.duty-flip-card {
|
||||
perspective: 600px;
|
||||
}
|
||||
.duty-flip-inner {
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
.duty-flip-front {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
.duty-flip-back {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
|
||||
[data-perf="low"] *,
|
||||
[data-perf="low"] *::before,
|
||||
[data-perf="low"] *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
|
||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||
.pt-safe {
|
||||
padding-top: var(--app-safe-top);
|
||||
}
|
||||
|
||||
/* Content safe area: top/bottom/left/right so content and sticky chrome sit below Telegram UI.
|
||||
Horizontal padding has a minimum of 0.75rem (12px) when safe insets are zero. */
|
||||
.content-safe {
|
||||
padding-top: var(--app-safe-top);
|
||||
padding-bottom: var(--app-safe-bottom);
|
||||
padding-left: max(var(--app-safe-left), 0.75rem);
|
||||
padding-right: max(var(--app-safe-right), 0.75rem);
|
||||
}
|
||||
|
||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||
.sticky.is-scrolled {
|
||||
box-shadow: 0 1px 0 0 var(--border);
|
||||
}
|
||||
|
||||
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
|
||||
.calendar-grid {
|
||||
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
|
||||
}
|
||||
|
||||
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
|
||||
@keyframes card-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.current-duty-card {
|
||||
box-shadow: var(--shadow-card);
|
||||
animation: card-appear 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
.current-duty-card--no-duty {
|
||||
border-top-color: var(--muted);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: var(--tg-viewport-stable-height, 100vh);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
47
webapp-next/src/app/layout.tsx
Normal file
47
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||
import { THEME_BOOTSTRAP_SCRIPT } from "@/lib/theme-bootstrap-script";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Duty Teller",
|
||||
description: "Team duty shift calendar and reminders",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
<script dangerouslySetInnerHTML={{ __html: THEME_BOOTSTRAP_SCRIPT }} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||
}}
|
||||
/>
|
||||
<script src="/app/config.js" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<TelegramProvider>
|
||||
<AppErrorBoundary>
|
||||
<TooltipProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</TooltipProvider>
|
||||
</AppErrorBoundary>
|
||||
</TelegramProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
30
webapp-next/src/app/not-found.tsx
Normal file
30
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||
* For static export with a single route this is rarely hit; added for consistency.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
useScreenReady(true);
|
||||
|
||||
return (
|
||||
<FullScreenStateShell
|
||||
title={t("not_found.title")}
|
||||
description={t("not_found.description")}
|
||||
primaryAction={
|
||||
<Button asChild>
|
||||
<Link href="/">{t("not_found.open_calendar")}</Link>
|
||||
</Button>
|
||||
}
|
||||
role="status"
|
||||
/>
|
||||
);
|
||||
}
|
||||
65
webapp-next/src/app/page.test.tsx
Normal file
65
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Integration test for main page: calendar and header visible, lang from store.
|
||||
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, act } from "@testing-library/react";
|
||||
import Page from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-month-data", () => ({
|
||||
useMonthData: () => ({
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders calendar and header when store has default state", async () => {
|
||||
render(<Page />);
|
||||
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||
initDataRaw: undefined,
|
||||
startParam: undefined,
|
||||
isLocalhost: false,
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
render(<Page />);
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(RETRY_DELAY_MS);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
expect(
|
||||
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 2000 })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Reload|Обновить/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("grid", { name: "Calendar" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
83
webapp-next/src/app/page.tsx
Normal file
83
webapp-next/src/app/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Main Mini App page: current duty deep link or calendar view.
|
||||
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useAppStore, type AppState } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { useAppInit } from "@/hooks/use-app-init";
|
||||
import { fetchAdminMe } from "@/lib/api";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||
import { CalendarPage } from "@/components/CalendarPage";
|
||||
import { MiniAppScreen, MiniAppScreenContent } from "@/components/layout/MiniAppScreen";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
export default function Home() {
|
||||
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
useAppInit({ isAllowed, startParam });
|
||||
|
||||
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) {
|
||||
setIsAdmin(false);
|
||||
return;
|
||||
}
|
||||
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
|
||||
}, [isAllowed, initDataRaw, setIsAdmin]);
|
||||
|
||||
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||
useAppStore(
|
||||
useShallow((s: AppState) => ({
|
||||
accessDenied: s.accessDenied,
|
||||
currentView: s.currentView,
|
||||
setCurrentView: s.setCurrentView,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
appContentReady: s.appContentReady,
|
||||
}))
|
||||
);
|
||||
|
||||
useScreenReady(accessDenied || currentView === "currentDuty");
|
||||
|
||||
const handleBackFromCurrentDuty = useCallback(() => {
|
||||
setCurrentView("calendar");
|
||||
setSelectedDay(null);
|
||||
}, [setCurrentView, setSelectedDay]);
|
||||
|
||||
const content = accessDenied ? (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<AccessDeniedScreen primaryAction="reload" />
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
) : currentView === "currentDuty" ? (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<CurrentDutyView
|
||||
onBack={handleBackFromCurrentDuty}
|
||||
openedFromPin={startParam === "duty"}
|
||||
/>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
) : (
|
||||
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
|
||||
style={{
|
||||
visibility: appContentReady ? "visible" : "hidden",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
71
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||
* on React context that might be broken.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FullScreenStateShell } from "@/components/states/FullScreenStateShell";
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AppErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||
* instead of crashing. Provides a Reload button to recover.
|
||||
*/
|
||||
export class AppErrorBoundary extends React.Component<
|
||||
AppErrorBoundaryProps,
|
||||
AppErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AppErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (typeof console !== "undefined" && console.error) {
|
||||
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const lang = getLang();
|
||||
const message = translate(lang, "error_boundary.message");
|
||||
const description = translate(lang, "error_boundary.description");
|
||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||
return (
|
||||
<FullScreenStateShell
|
||||
title={message}
|
||||
description={description}
|
||||
primaryAction={
|
||||
<Button type="button" variant="default" onClick={this.handleReload}>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
17
webapp-next/src/components/AppShell.tsx
Normal file
17
webapp-next/src/components/AppShell.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
webapp-next/src/components/CalendarPage.tsx
Normal file
181
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Calendar view layout: header, grid, duty list, day detail.
|
||||
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList } from "@/components/duty/DutyList";
|
||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { MiniAppScreen, MiniAppScreenContent, MiniAppStickyHeader } from "@/components/layout/MiniAppScreen";
|
||||
import { useTelegramSettingsButton, useTelegramVerticalSwipePolicy } from "@/hooks/telegram";
|
||||
import { DISABLE_VERTICAL_SWIPES_BY_DEFAULT } from "@/lib/telegram-interaction-policy";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
|
||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||
|
||||
export interface CalendarPageProps {
|
||||
/** Whether the user is allowed (for data loading). */
|
||||
isAllowed: boolean;
|
||||
/** Raw initData string for API auth. */
|
||||
initDataRaw: string | undefined;
|
||||
}
|
||||
|
||||
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||
|
||||
useEffect(() => {
|
||||
const el = calendarStickyRef.current;
|
||||
if (!el) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
currentMonth,
|
||||
pendingMonth,
|
||||
loading,
|
||||
error,
|
||||
accessDenied,
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
isAdmin,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
setSelectedDay,
|
||||
} = useAppStore(
|
||||
useShallow((s) => ({
|
||||
currentMonth: s.currentMonth,
|
||||
pendingMonth: s.pendingMonth,
|
||||
loading: s.loading,
|
||||
error: s.error,
|
||||
accessDenied: s.accessDenied,
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
isAdmin: s.isAdmin,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
setSelectedDay: s.setSelectedDay,
|
||||
}))
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const isCurrentMonth =
|
||||
currentMonth.getFullYear() === now.getFullYear() &&
|
||||
currentMonth.getMonth() === now.getMonth();
|
||||
useAutoRefresh(retry, isCurrentMonth);
|
||||
|
||||
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||
const handlePrevMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
prevMonth();
|
||||
}, [navDisabled, prevMonth]);
|
||||
const handleNextMonth = useCallback(() => {
|
||||
if (navDisabled) return;
|
||||
nextMonth();
|
||||
}, [navDisabled, nextMonth]);
|
||||
|
||||
useSwipe(
|
||||
calendarStickyRef,
|
||||
handleNextMonth,
|
||||
handlePrevMonth,
|
||||
{ threshold: 50, disabled: navDisabled }
|
||||
);
|
||||
useStickyScroll(calendarStickyRef);
|
||||
useTelegramVerticalSwipePolicy(DISABLE_VERTICAL_SWIPES_BY_DEFAULT);
|
||||
|
||||
const handleDayClick = useCallback(
|
||||
(dateKey: string, anchorRect: DOMRect) => {
|
||||
const [y, m] = dateKey.split("-").map(Number);
|
||||
if (
|
||||
y !== currentMonth.getFullYear() ||
|
||||
m !== currentMonth.getMonth() + 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||
},
|
||||
[currentMonth]
|
||||
);
|
||||
|
||||
const handleCloseDayDetail = useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
}, [setSelectedDay]);
|
||||
|
||||
useScreenReady(!loading || accessDenied);
|
||||
|
||||
useTelegramSettingsButton({
|
||||
enabled: isAdmin,
|
||||
onClick: () => router.push("/admin"),
|
||||
});
|
||||
|
||||
return (
|
||||
<MiniAppScreen>
|
||||
<MiniAppScreenContent>
|
||||
<MiniAppStickyHeader
|
||||
ref={calendarStickyRef}
|
||||
className="min-h-[var(--calendar-block-min-height)] pb-2 touch-pan-y"
|
||||
>
|
||||
<CalendarHeader
|
||||
month={currentMonth}
|
||||
disabled={navDisabled}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
/>
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
</MiniAppStickyHeader>
|
||||
|
||||
{error && (
|
||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||
)}
|
||||
{!error && (
|
||||
<DutyList
|
||||
scrollMarginTop={stickyBlockHeight}
|
||||
className="mt-2"
|
||||
/>
|
||||
)}
|
||||
|
||||
<DayDetail
|
||||
ref={dayDetailRef}
|
||||
duties={duties}
|
||||
calendarEvents={calendarEvents}
|
||||
onClose={handleCloseDayDetail}
|
||||
/>
|
||||
</MiniAppScreenContent>
|
||||
</MiniAppScreen>
|
||||
);
|
||||
}
|
||||
23
webapp-next/src/components/ReadyGate.tsx
Normal file
23
webapp-next/src/components/ReadyGate.tsx
Normal 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;
|
||||
}
|
||||
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
120
webapp-next/src/components/admin/AdminDutyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
197
webapp-next/src/components/admin/ReassignSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
webapp-next/src/components/admin/hooks/index.ts
Normal file
5
webapp-next/src/components/admin/hooks/index.ts
Normal 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";
|
||||
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal file
44
webapp-next/src/components/admin/hooks/use-admin-access.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal file
49
webapp-next/src/components/admin/hooks/use-admin-duties.ts
Normal 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 };
|
||||
}
|
||||
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal file
116
webapp-next/src/components/admin/hooks/use-admin-reassign.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal file
45
webapp-next/src/components/admin/hooks/use-admin-users.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
9
webapp-next/src/components/admin/index.ts
Normal file
9
webapp-next/src/components/admin/index.ts
Normal 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";
|
||||
129
webapp-next/src/components/admin/useAdminPage.ts
Normal file
129
webapp-next/src/components/admin/useAdminPage.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
95
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
95
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarDay", () => {
|
||||
const defaultProps = {
|
||||
dateKey: "2025-02-15",
|
||||
dayOfMonth: 15,
|
||||
isToday: false,
|
||||
duties: [],
|
||||
eventSummaries: [],
|
||||
onDayClick: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||
expect(onDayClick).toHaveBeenCalledWith(
|
||||
"2025-02-15",
|
||||
expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={true}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
|
||||
it("applies today base and holiday outline when isToday and eventSummaries are set", () => {
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
isToday={true}
|
||||
eventSummaries={["Holiday"]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.className).toMatch(/bg-today|today/);
|
||||
expect(button.className).toMatch(/ring-2|today-holiday-outline/);
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
});
|
||||
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Single calendar day cell: date number and day indicators. Click opens day detail.
|
||||
* Ported from webapp/js/calendar.js day cell rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
export interface CalendarDayProps {
|
||||
/** YYYY-MM-DD key for this day. */
|
||||
dateKey: string;
|
||||
/** Day of month (1–31) for display. */
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isOtherMonth: boolean;
|
||||
/** Duties overlapping this day (for indicators and tooltip). */
|
||||
duties: DutyWithUser[];
|
||||
/** External calendar event summaries for this day. */
|
||||
eventSummaries: string[];
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function CalendarDayInner({
|
||||
dateKey,
|
||||
dayOfMonth,
|
||||
isToday,
|
||||
isOtherMonth,
|
||||
duties,
|
||||
eventSummaries,
|
||||
onDayClick,
|
||||
}: CalendarDayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dutyList, unavailableList, vacationList } = useMemo(
|
||||
() => ({
|
||||
dutyList: duties.filter((d) => d.event_type === "duty"),
|
||||
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
|
||||
vacationList: duties.filter((d) => d.event_type === "vacation"),
|
||||
}),
|
||||
[duties]
|
||||
);
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
const isTodayHoliday = isToday && hasEvent;
|
||||
const showIndicator = !isOtherMonth;
|
||||
const hasAny = duties.length > 0 || hasEvent;
|
||||
|
||||
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||
if (hasAny && showIndicator) {
|
||||
const counts: string[] = [];
|
||||
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||
if (unavailableList.length)
|
||||
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||
if (vacationList.length)
|
||||
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||
if (hasEvent) counts.push(t("hint.events"));
|
||||
ariaParts.push(counts.join(", "));
|
||||
} else {
|
||||
ariaParts.push(t("aria.day_info"));
|
||||
}
|
||||
const ariaLabel = ariaParts.join("; ");
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isOtherMonth}
|
||||
data-date={dateKey}
|
||||
className={cn(
|
||||
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
isOtherMonth &&
|
||||
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||
!isOtherMonth && [
|
||||
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||
"active:scale-[0.98] cursor-pointer",
|
||||
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||
],
|
||||
showIndicator && hasAny && "font-bold",
|
||||
showIndicator &&
|
||||
hasEvent &&
|
||||
!isToday &&
|
||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||
isTodayHoliday && "ring-1 ring-inset ring-[var(--today-holiday-outline)]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isOtherMonth) return;
|
||||
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
<span className="num">{dayOfMonth}</span>
|
||||
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||
<DayIndicators
|
||||
dutyCount={dutyList.length}
|
||||
unavailableCount={unavailableList.length}
|
||||
vacationCount={vacationList.length}
|
||||
hasEvents={hasEvent}
|
||||
isToday={isToday}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||
return (
|
||||
prev.dateKey === next.dateKey &&
|
||||
prev.dayOfMonth === next.dayOfMonth &&
|
||||
prev.isToday === next.isToday &&
|
||||
prev.isOtherMonth === next.isOtherMonth &&
|
||||
prev.duties === next.duties &&
|
||||
prev.eventSummaries === next.eventSummaries &&
|
||||
prev.onDayClick === next.onDayClick
|
||||
);
|
||||
}
|
||||
|
||||
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
|
||||
* Ported from webapp/js/calendar.test.js renderCalendar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CalendarGrid } from "./CalendarGrid";
|
||||
import { CalendarHeader } from "./CalendarHeader";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarGrid", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders 42 cells (6 weeks)", () => {
|
||||
const currentMonth = new Date(2025, 0, 1); // January 2025
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const cells = screen.getAllByRole("button", { name: /;/ });
|
||||
expect(cells.length).toBe(42);
|
||||
});
|
||||
|
||||
it("sets data-date on each cell to YYYY-MM-DD", () => {
|
||||
const currentMonth = new Date(2025, 0, 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const grid = screen.getByRole("grid", { name: "Calendar" });
|
||||
const buttons = grid.querySelectorAll('button[data-date]');
|
||||
expect(buttons.length).toBe(42);
|
||||
buttons.forEach((el) => {
|
||||
const date = el.getAttribute("data-date");
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds today styling to cell matching today", () => {
|
||||
const today = new Date();
|
||||
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const todayKey = `${year}-${month}-${day}`;
|
||||
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
|
||||
expect(todayCell).toBeTruthy();
|
||||
expect(todayCell?.className).toMatch(/today|bg-today/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarHeader", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sets month title from lang and given year/month", () => {
|
||||
render(
|
||||
<CalendarHeader
|
||||
month={new Date(2025, 1, 1)}
|
||||
onPrevMonth={() => {}}
|
||||
onNextMonth={() => {}}
|
||||
/>
|
||||
);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveTextContent("February");
|
||||
expect(heading).toHaveTextContent("2025");
|
||||
});
|
||||
});
|
||||
95
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
95
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
|
||||
* Ported from webapp/js/calendar.js renderCalendar.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
getMonday,
|
||||
localDateString,
|
||||
} from "@/lib/date-utils";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
|
||||
export interface CalendarGridProps {
|
||||
/** Currently displayed month. */
|
||||
currentMonth: Date;
|
||||
/** All duties for the visible range (will be grouped by date). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CELLS = 42;
|
||||
|
||||
export function CalendarGrid({
|
||||
currentMonth,
|
||||
duties,
|
||||
calendarEvents,
|
||||
onDayClick,
|
||||
className,
|
||||
}: CalendarGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const dutiesByDateMap = useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const calendarEventsByDateMap = useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
const todayKey = localDateString(new Date());
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const first = firstDayOfMonth(currentMonth);
|
||||
const start = getMonday(first);
|
||||
const result: { date: Date; key: string; month: number }[] = [];
|
||||
const d = new Date(start);
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
const key = localDateString(d);
|
||||
result.push({ date: new Date(d), key, month: d.getMonth() });
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}, [currentMonth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
|
||||
className
|
||||
)}
|
||||
role="grid"
|
||||
aria-label={t("aria.calendar")}
|
||||
>
|
||||
{cells.map(({ date, key, month }, i) => {
|
||||
const isOtherMonth = month !== currentMonth.getMonth();
|
||||
const dayDuties = dutiesByDateMap[key] ?? [];
|
||||
const eventSummaries = calendarEventsByDateMap[key] ?? [];
|
||||
|
||||
return (
|
||||
<div key={`cell-${i}`} role="gridcell" className="min-h-0">
|
||||
<CalendarDay
|
||||
dateKey={key}
|
||||
dayOfMonth={date.getDate()}
|
||||
isToday={key === todayKey}
|
||||
isOtherMonth={isOtherMonth}
|
||||
duties={dayDuties}
|
||||
eventSummaries={eventSummaries}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
51
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Calendar header: month title, prev/next navigation, weekday labels.
|
||||
* Replaces the header from webapp index.html and calendar.js month title.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MonthNavHeader } from "@/components/calendar/MonthNavHeader";
|
||||
|
||||
export interface CalendarHeaderProps {
|
||||
/** Currently displayed month (used for title). */
|
||||
month: Date;
|
||||
/** Whether month navigation is disabled (e.g. during loading). */
|
||||
disabled?: boolean;
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CalendarHeader({
|
||||
month,
|
||||
disabled = false,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
className,
|
||||
}: CalendarHeaderProps) {
|
||||
const { weekdayLabels } = useTranslation();
|
||||
const labels = weekdayLabels();
|
||||
|
||||
return (
|
||||
<header className={cn("flex flex-col", className)}>
|
||||
<MonthNavHeader
|
||||
month={month}
|
||||
disabled={disabled}
|
||||
onPrevMonth={onPrevMonth}
|
||||
onNextMonth={onNextMonth}
|
||||
ariaLive
|
||||
className="mb-3"
|
||||
/>
|
||||
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
||||
{labels.map((label, i) => (
|
||||
<span key={i} aria-hidden>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
71
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
71
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Unit tests for DayIndicators: rounding is position-based (first / last segment),
|
||||
* not by indicator type, so one or multiple segments form one pill.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
const baseProps = {
|
||||
dutyCount: 0,
|
||||
unavailableCount: 0,
|
||||
vacationCount: 0,
|
||||
hasEvents: false,
|
||||
};
|
||||
|
||||
describe("DayIndicators", () => {
|
||||
it("renders nothing when no indicators", () => {
|
||||
const { container } = render(<DayIndicators {...baseProps} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders one segment as pill (e.g. vacation only)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain("[&>:first-child]:rounded-l-[3px]");
|
||||
expect(wrapper?.className).toContain("[&>:last-child]:rounded-r-[3px]");
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renders two segments with positional rounding (duty + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators {...baseProps} dutyCount={1} vacationCount={1} />
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("renders three segments with first left-rounded, last right-rounded (duty + unavailable + vacation)", () => {
|
||||
const { container } = render(
|
||||
<DayIndicators
|
||||
{...baseProps}
|
||||
dutyCount={1}
|
||||
unavailableCount={1}
|
||||
vacationCount={1}
|
||||
/>
|
||||
);
|
||||
const wrapper = container.querySelector("[aria-hidden]");
|
||||
expect(wrapper).toBeInTheDocument();
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:first-child]:rounded-l-[3px]"
|
||||
);
|
||||
expect(wrapper?.className).toContain(
|
||||
"[&>:last-child]:rounded-r-[3px]"
|
||||
);
|
||||
const spans = wrapper?.querySelectorAll("span");
|
||||
expect(spans).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
65
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
65
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Colored segments (pill bar) for calendar day: duty (green), unavailable (amber), vacation (blue), events (accent).
|
||||
* Ported from webapp calendar day-indicator markup and markers.css.
|
||||
*
|
||||
* Rounding is position-based (first / last segment), so one or multiple segments always form a pill:
|
||||
* first segment gets left rounding, last segment gets right rounding (single segment gets both).
|
||||
*/
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface DayIndicatorsProps {
|
||||
/** Number of duty slots this day. */
|
||||
dutyCount: number;
|
||||
/** Number of unavailable slots. */
|
||||
unavailableCount: number;
|
||||
/** Number of vacation slots. */
|
||||
vacationCount: number;
|
||||
/** Whether the day has external calendar events (e.g. holiday). */
|
||||
hasEvents: boolean;
|
||||
/** When true (e.g. today cell), use darker segments for contrast. */
|
||||
isToday?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayIndicators({
|
||||
dutyCount,
|
||||
unavailableCount,
|
||||
vacationCount,
|
||||
hasEvents,
|
||||
isToday = false,
|
||||
className,
|
||||
}: DayIndicatorsProps) {
|
||||
const hasAny = dutyCount > 0 || unavailableCount > 0 || vacationCount > 0 || hasEvents;
|
||||
if (!hasAny) return null;
|
||||
|
||||
const dotClass = (variant: "duty" | "unavailable" | "vacation" | "events") =>
|
||||
cn(
|
||||
"min-w-0 flex-1 h-1 max-h-1.5",
|
||||
variant === "duty" && "bg-duty",
|
||||
variant === "unavailable" && "bg-unavailable",
|
||||
variant === "vacation" && "bg-vacation",
|
||||
variant === "events" && "bg-accent",
|
||||
isToday && variant === "duty" && "bg-[var(--indicator-today-duty)]",
|
||||
isToday && variant === "unavailable" && "bg-[var(--indicator-today-unavailable)]",
|
||||
isToday && variant === "vacation" && "bg-[var(--indicator-today-vacation)]",
|
||||
isToday && variant === "events" && "bg-[var(--indicator-today-events)]"
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-[65%] justify-center gap-0.5 mt-1.5",
|
||||
"[&>:first-child]:rounded-l-[3px]",
|
||||
"[&>:last-child]:rounded-r-[3px]",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
{dutyCount > 0 && <span className={dotClass("duty")} />}
|
||||
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
|
||||
{vacationCount > 0 && <span className={dotClass("vacation")} />}
|
||||
{hasEvents && <span className={dotClass("events")} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
webapp-next/src/components/calendar/MonthNavHeader.tsx
Normal file
86
webapp-next/src/components/calendar/MonthNavHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
webapp-next/src/components/calendar/index.ts
Normal file
12
webapp-next/src/components/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Calendar components and data helpers.
|
||||
*/
|
||||
|
||||
export { CalendarGrid } from "./CalendarGrid";
|
||||
export { CalendarHeader } from "./CalendarHeader";
|
||||
export { CalendarDay } from "./CalendarDay";
|
||||
export { DayIndicators } from "./DayIndicators";
|
||||
export type { DayIndicatorsProps } from "./DayIndicators";
|
||||
export type { CalendarGridProps } from "./CalendarGrid";
|
||||
export type { CalendarHeaderProps } from "./CalendarHeader";
|
||||
export type { CalendarDayProps } from "./CalendarDay";
|
||||
261
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
261
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
|
||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { ContactLinks } from "./ContactLinks";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
function renderWithTooltip(ui: React.ReactElement) {
|
||||
return render(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
const openPhoneLinkMock = vi.fn();
|
||||
const openTelegramProfileMock = vi.fn();
|
||||
const triggerHapticLightMock = vi.fn();
|
||||
const copyToClipboardMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/open-phone-link", () => ({
|
||||
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/telegram-link", () => ({
|
||||
openTelegramProfile: (...args: unknown[]) => openTelegramProfileMock(...args),
|
||||
}));
|
||||
vi.mock("@/lib/telegram-haptic", () => ({
|
||||
triggerHapticLight: () => triggerHapticLightMock(),
|
||||
}));
|
||||
vi.mock("@/lib/copy-to-clipboard", () => ({
|
||||
copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args),
|
||||
}));
|
||||
|
||||
describe("ContactLinks", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
openPhoneLinkMock.mockClear();
|
||||
openTelegramProfileMock.mockClear();
|
||||
triggerHapticLightMock.mockClear();
|
||||
copyToClipboardMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns null when phone and username are missing", () => {
|
||||
const { container } = render(
|
||||
<ContactLinks phone={null} username={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders phone only with label and tel: link", () => {
|
||||
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays phone formatted for Russian numbers", () => {
|
||||
render(<ContactLinks phone="79146522209" username={null} />);
|
||||
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders username only with label and t.me link", () => {
|
||||
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both phone and username with labels", () => {
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79001112233"
|
||||
username="bob"
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/@bob/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("strips leading @ from username and displays with @", () => {
|
||||
render(<ContactLinks phone={null} username="@alice" />);
|
||||
const link = document.querySelector('a[href*="t.me/alice"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link?.textContent).toContain("@alice");
|
||||
});
|
||||
|
||||
it("calls openPhoneLink and triggerHapticLight when phone link is clicked", () => {
|
||||
render(
|
||||
<ContactLinks phone="+79991234567" username={null} showLabels={false} />
|
||||
);
|
||||
const telLink = document.querySelector<HTMLAnchorElement>('a[href^="tel:"]');
|
||||
expect(telLink).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(telLink!);
|
||||
|
||||
expect(openPhoneLinkMock).toHaveBeenCalledWith("+79991234567");
|
||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls openTelegramProfile and triggerHapticLight when Telegram link is clicked", () => {
|
||||
render(
|
||||
<ContactLinks phone={null} username="alice_dev" showLabels={false} />
|
||||
);
|
||||
const tgLink = document.querySelector<HTMLAnchorElement>('a[href*="t.me"]');
|
||||
expect(tgLink).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(tgLink!);
|
||||
|
||||
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
|
||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("showCopyButtons with layout block", () => {
|
||||
it("renders copy phone button with aria-label when phone is present", () => {
|
||||
renderWithTooltip(
|
||||
<ContactLinks
|
||||
phone="+79991234567"
|
||||
username={null}
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders copy Telegram button with aria-label when username is present", () => {
|
||||
renderWithTooltip(
|
||||
<ContactLinks
|
||||
phone={null}
|
||||
username="alice_dev"
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => {
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
renderWithTooltip(
|
||||
<ContactLinks
|
||||
phone="+79991234567"
|
||||
username={null}
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
const copyBtn = screen.getByRole("button", {
|
||||
name: /Copy phone number|Скопировать номер/i,
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(copyBtn);
|
||||
});
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567");
|
||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls copyToClipboard with @username when copy Telegram is clicked", async () => {
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
renderWithTooltip(
|
||||
<ContactLinks
|
||||
phone={null}
|
||||
username="alice_dev"
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
const copyBtn = screen.getByRole("button", {
|
||||
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(copyBtn);
|
||||
});
|
||||
|
||||
expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev");
|
||||
expect(triggerHapticLightMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Copied via button aria-label and no tooltip after successful copy", async () => {
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79991234567"
|
||||
username={null}
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
const copyBtn = screen.getByRole("button", {
|
||||
name: /Copy phone number|Скопировать номер/i,
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(copyBtn);
|
||||
});
|
||||
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reverts first copy button to Copy icon when copying the other field", async () => {
|
||||
copyToClipboardMock.mockResolvedValue(true);
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79991234567"
|
||||
username="alice_dev"
|
||||
layout="block"
|
||||
showCopyButtons
|
||||
/>
|
||||
);
|
||||
const copyPhoneBtn = screen.getByRole("button", {
|
||||
name: /Copy phone number|Скопировать номер/i,
|
||||
});
|
||||
const copyTelegramBtn = screen.getByRole("button", {
|
||||
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.click(copyPhoneBtn);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||
).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(copyTelegramBtn);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copied|Скопировано/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show copy buttons when showCopyButtons is false", () => {
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79991234567"
|
||||
username="bob"
|
||||
layout="block"
|
||||
showCopyButtons={false}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
247
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
247
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Contact links (phone, Telegram) for duty cards and day detail.
|
||||
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||
import { openPhoneLink } from "@/lib/open-phone-link";
|
||||
import { openTelegramProfile } from "@/lib/telegram-link";
|
||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||
import { copyToClipboard } from "@/lib/copy-to-clipboard";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Phone as PhoneIcon, Send as TelegramIcon, Copy, Check } from "lucide-react";
|
||||
|
||||
const COPIED_RESET_MS = 1800;
|
||||
|
||||
export interface ContactLinksProps {
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
layout?: "inline" | "block";
|
||||
showLabels?: boolean;
|
||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||
contextLabel?: string;
|
||||
/** When true and layout is "block", show copy buttons for phone and Telegram username. */
|
||||
showCopyButtons?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
||||
|
||||
/**
|
||||
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
||||
*/
|
||||
export function ContactLinks({
|
||||
phone,
|
||||
username,
|
||||
layout = "inline",
|
||||
showLabels = true,
|
||||
contextLabel,
|
||||
showCopyButtons = false,
|
||||
className,
|
||||
}: ContactLinksProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null);
|
||||
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const hasPhone = Boolean(phone && String(phone).trim());
|
||||
const rawUsername = username && String(username).trim();
|
||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||
const hasUsername = Boolean(cleanUsername);
|
||||
|
||||
const showCopy = layout === "block" && showCopyButtons;
|
||||
|
||||
useEffect(() => () => clearCopiedTimeout(), []);
|
||||
|
||||
const clearCopiedTimeout = () => {
|
||||
if (copiedTimeoutRef.current) {
|
||||
clearTimeout(copiedTimeoutRef.current);
|
||||
copiedTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const showCopiedFeedback = (kind: "phone" | "telegram") => {
|
||||
clearCopiedTimeout();
|
||||
setCopiedKind(kind);
|
||||
copiedTimeoutRef.current = setTimeout(() => {
|
||||
setCopiedKind(null);
|
||||
copiedTimeoutRef.current = null;
|
||||
}, COPIED_RESET_MS);
|
||||
};
|
||||
|
||||
if (!hasPhone && !hasUsername) return null;
|
||||
|
||||
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
openPhoneLink(phone ?? undefined);
|
||||
triggerHapticLight();
|
||||
};
|
||||
|
||||
const handleCopyPhone = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
triggerHapticLight();
|
||||
const rawPhone = String(phone).trim();
|
||||
const ok = await copyToClipboard(rawPhone);
|
||||
if (ok) showCopiedFeedback("phone");
|
||||
};
|
||||
|
||||
const handleCopyTelegram = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
triggerHapticLight();
|
||||
const text = `@${cleanUsername}`;
|
||||
const ok = await copyToClipboard(text);
|
||||
if (ok) showCopiedFeedback("telegram");
|
||||
};
|
||||
|
||||
const ariaCall = contextLabel
|
||||
? t("contact.aria_call", { name: contextLabel })
|
||||
: t("contact.phone");
|
||||
const ariaTelegram = contextLabel
|
||||
? t("contact.aria_telegram", { name: contextLabel })
|
||||
: t("contact.telegram");
|
||||
|
||||
if (layout === "block") {
|
||||
const rowClass =
|
||||
"flex h-12 items-center gap-0 rounded-md border border-input bg-background shadow-xs text-accent hover:bg-accent/10 hover:text-accent dark:bg-input/30 dark:hover:bg-input/50";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{hasPhone && (
|
||||
<div className={rowClass}>
|
||||
<a
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
aria-label={ariaCall}
|
||||
onClick={handlePhoneClick}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<PhoneIcon className="size-5 shrink-0" aria-hidden />
|
||||
<span className="truncate">{formatPhoneDisplay(phone!)}</span>
|
||||
</a>
|
||||
{showCopy && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||
aria-label={copiedKind === "phone" ? t("contact.copied") : t("contact.copy_phone")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleCopyPhone(e);
|
||||
}}
|
||||
>
|
||||
{copiedKind === "phone" ? (
|
||||
<Check className="size-5" aria-hidden />
|
||||
) : (
|
||||
<Copy className="size-5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{hasUsername && (
|
||||
<div className={rowClass}>
|
||||
<a
|
||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaTelegram}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
openTelegramProfile(cleanUsername);
|
||||
triggerHapticLight();
|
||||
}}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
|
||||
>
|
||||
<TelegramIcon className="size-5 shrink-0" aria-hidden />
|
||||
<span className="truncate">@{cleanUsername}</span>
|
||||
</a>
|
||||
{showCopy && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
|
||||
aria-label={copiedKind === "telegram" ? t("contact.copied") : t("contact.copy_telegram")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void handleCopyTelegram(e);
|
||||
}}
|
||||
>
|
||||
{copiedKind === "telegram" ? (
|
||||
<Check className="size-5" aria-hidden />
|
||||
) : (
|
||||
<Copy className="size-5" aria-hidden />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (hasPhone) {
|
||||
const displayPhone = formatPhoneDisplay(phone!);
|
||||
parts.push(
|
||||
showLabels ? (
|
||||
<span key="phone">
|
||||
{t("contact.phone")}:{" "}
|
||||
<a
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
onClick={handlePhoneClick}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
key="phone"
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
onClick={handlePhoneClick}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (hasUsername) {
|
||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||
const handleTelegramClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
openTelegramProfile(cleanUsername);
|
||||
triggerHapticLight();
|
||||
};
|
||||
const link = (
|
||||
<a
|
||||
key="tg"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
aria-label={ariaTelegram}
|
||||
onClick={handleTelegramClick}
|
||||
>
|
||||
@{cleanUsername}
|
||||
</a>
|
||||
);
|
||||
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-x-1">
|
||||
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
webapp-next/src/components/contact/index.ts
Normal file
6
webapp-next/src/components/contact/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Contact links component.
|
||||
*/
|
||||
|
||||
export { ContactLinks } from "./ContactLinks";
|
||||
export type { ContactLinksProps } from "./ContactLinks";
|
||||
185
webapp-next/src/components/current-duty/CurrentDutyView.test.tsx
Normal file
185
webapp-next/src/components/current-duty/CurrentDutyView.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
|
||||
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CurrentDutyView } from "./CurrentDutyView";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchDuties: vi.fn().mockResolvedValue([]),
|
||||
AccessDeniedError: class AccessDeniedError extends Error {
|
||||
serverDetail?: string;
|
||||
constructor(m: string, d?: string) {
|
||||
super(m);
|
||||
this.serverDetail = d;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CurrentDutyView", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading then no-duty message when no active duty", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("back button calls onBack when clicked", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Close button when openedFromPin is true", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Open calendar button in no-duty view and it calls onBack", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
const openCalendarBtn = screen.getByRole("button", {
|
||||
name: /Open calendar|Открыть календарь/i,
|
||||
});
|
||||
expect(openCalendarBtn).toBeInTheDocument();
|
||||
fireEvent.click(openCalendarBtn);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows contact info not set when duty has no phone or username", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
const dutyNoContacts = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows ends_at line when duty is active", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const end = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||
const duty = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([duty]);
|
||||
render(<CurrentDutyView onBack={vi.fn()} />);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Until end of shift at|До конца смены в/i)
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("error state shows Retry as first button", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
vi.mocked(fetchDuties).mockRejectedValue(new Error("Network error"));
|
||||
render(<CurrentDutyView onBack={vi.fn()} />);
|
||||
await screen.findByText(/Could not load|Не удалось загрузить/i, {}, { timeout: 3000 });
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i);
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("403 shows AccessDeniedScreen with Back button and no Retry", async () => {
|
||||
const { fetchDuties, AccessDeniedError } = await import("@/lib/api");
|
||||
vi.mocked(fetchDuties).mockRejectedValue(
|
||||
new AccessDeniedError("ACCESS_DENIED", "Custom 403 message")
|
||||
);
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /Retry|Повторить/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i }));
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("shows copy phone and copy Telegram buttons when duty has contacts", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000);
|
||||
const dutyWithContacts = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: "+79991234567",
|
||||
username: "testuser",
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]);
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CurrentDutyView onBack={vi.fn()} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: /Copy Telegram username|Скопировать логин Telegram/i,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
});
|
||||
357
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
357
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
||||
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
|
||||
* and contact links. Integrates with Telegram BackButton.
|
||||
* Ported from webapp/js/currentDuty.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { translate } from "@/i18n/messages";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { useTelegramBackButton, useTelegramCloseAction } from "@/hooks/telegram";
|
||||
import { useScreenReady } from "@/hooks/use-screen-ready";
|
||||
import { useRequestState } from "@/hooks/use-request-state";
|
||||
|
||||
export interface CurrentDutyViewProps {
|
||||
/** Called when user taps Back (in-app button or Telegram BackButton). */
|
||||
onBack: () => void;
|
||||
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
|
||||
openedFromPin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||
*/
|
||||
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const { initDataRaw } = useTelegramAuth();
|
||||
|
||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||
const {
|
||||
state: requestState,
|
||||
setLoading,
|
||||
setSuccess,
|
||||
setError,
|
||||
setAccessDenied,
|
||||
isLoading,
|
||||
isError,
|
||||
isAccessDenied,
|
||||
} = useRequestState("loading");
|
||||
|
||||
const loadTodayDuties = useCallback(
|
||||
async (signal?: AbortSignal | null) => {
|
||||
const today = new Date();
|
||||
const from = localDateString(today);
|
||||
const to = from;
|
||||
const initData = initDataRaw ?? "";
|
||||
try {
|
||||
const duties = await fetchDuties(from, to, initData, lang, signal);
|
||||
if (signal?.aborted) return;
|
||||
const active = findCurrentDuty(duties);
|
||||
setDuty(active);
|
||||
setSuccess();
|
||||
if (active) {
|
||||
setRemaining(getRemainingTime(active.end_at));
|
||||
} else {
|
||||
setRemaining(null);
|
||||
}
|
||||
} catch (e) {
|
||||
if (signal?.aborted) return;
|
||||
if (e instanceof AccessDeniedError) {
|
||||
setAccessDenied(e.serverDetail ?? null);
|
||||
setDuty(null);
|
||||
setRemaining(null);
|
||||
} else {
|
||||
setError(translate(lang, "error_generic"));
|
||||
setDuty(null);
|
||||
setRemaining(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[initDataRaw, lang, setSuccess, setAccessDenied, setError]
|
||||
);
|
||||
|
||||
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
loadTodayDuties(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, [loadTodayDuties]);
|
||||
|
||||
useScreenReady(!isLoading);
|
||||
|
||||
// Auto-update remaining time every second when there is an active duty.
|
||||
useEffect(() => {
|
||||
if (!duty) return;
|
||||
const interval = setInterval(() => {
|
||||
setRemaining(getRemainingTime(duty.end_at));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [duty]);
|
||||
|
||||
useTelegramBackButton({
|
||||
enabled: true,
|
||||
onClick: onBack,
|
||||
});
|
||||
|
||||
const handleBack = () => {
|
||||
triggerHapticLight();
|
||||
onBack();
|
||||
};
|
||||
|
||||
const closeMiniAppOrFallback = useTelegramCloseAction(onBack);
|
||||
|
||||
const handleClose = () => {
|
||||
triggerHapticLight();
|
||||
closeMiniAppOrFallback();
|
||||
};
|
||||
|
||||
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
||||
const primaryButtonAriaLabel = openedFromPin
|
||||
? t("current_duty.close")
|
||||
: t("current_duty.back");
|
||||
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[50vh] flex-col items-center justify-center gap-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
|
||||
<CardContent className="flex flex-col gap-4 pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="size-2 shrink-0 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-full" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-7 w-48" />
|
||||
<Skeleton className="h-4 w-full max-w-[200px]" />
|
||||
<Skeleton className="h-4 w-full max-w-[280px]" />
|
||||
</div>
|
||||
<Skeleton className="h-14 w-full rounded-lg" />
|
||||
<div className="flex flex-col gap-2 border-t border-border/50 pt-4">
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
<Skeleton className="h-12 w-full rounded-md" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAccessDenied) {
|
||||
return (
|
||||
<AccessDeniedScreen
|
||||
serverDetail={requestState.accessDeniedDetail}
|
||||
primaryAction="back"
|
||||
onBack={handlePrimaryAction}
|
||||
openedFromPin={openedFromPin}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
const handleRetry = () => {
|
||||
triggerHapticLight();
|
||||
setLoading();
|
||||
loadTodayDuties();
|
||||
};
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<Card className="w-full max-w-[var(--max-width-app)]">
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-error">{requestState.error}</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleRetry}
|
||||
aria-label={t("error.retry")}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!duty) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("current_duty.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-4">
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<Calendar className="size-12" strokeWidth={1.5} />
|
||||
</span>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{t("current_duty.no_duty")}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
aria-label={t("current_duty.open_calendar")}
|
||||
>
|
||||
{t("current_duty.open_calendar")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startLocal = localDateString(new Date(duty.start_at));
|
||||
const endLocal = localDateString(new Date(duty.end_at));
|
||||
const startDDMM = dateKeyToDDMM(startLocal);
|
||||
const endDDMM = dateKeyToDDMM(endLocal);
|
||||
const startTime = formatHHMM(duty.start_at);
|
||||
const endTime = formatHHMM(duty.end_at);
|
||||
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
||||
const rem = remaining ?? getRemainingTime(duty.end_at);
|
||||
const remainingValueStr = t("current_duty.remaining_value", {
|
||||
hours: String(rem.hours),
|
||||
minutes: String(rem.minutes),
|
||||
});
|
||||
|
||||
const displayTz =
|
||||
typeof window !== "undefined" &&
|
||||
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
|
||||
const shiftLabel = displayTz
|
||||
? t("current_duty.shift_tz", { tz: displayTz })
|
||||
: t("current_duty.shift_local");
|
||||
|
||||
const hasContacts =
|
||||
Boolean(duty.phone && String(duty.phone).trim()) ||
|
||||
Boolean(duty.username && String(duty.username).trim());
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
|
||||
<Card
|
||||
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
||||
role="article"
|
||||
aria-labelledby="current-duty-title"
|
||||
>
|
||||
<CardHeader className="sr-only">
|
||||
<CardTitle id="current-duty-title">{t("current_duty.title")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 pt-6">
|
||||
<span
|
||||
className="inline-flex w-fit items-center gap-2 rounded-full bg-duty/15 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<span className="size-2 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none" />
|
||||
{t("duty.now_on_duty")}
|
||||
</span>
|
||||
<section className="flex flex-col gap-2" aria-label={t("current_duty.shift")}>
|
||||
<p
|
||||
className="text-xl font-bold text-foreground leading-tight"
|
||||
id="current-duty-name"
|
||||
>
|
||||
{duty.full_name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
{shiftLabel}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground break-words">
|
||||
{shiftStr}
|
||||
</p>
|
||||
</section>
|
||||
<div
|
||||
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
||||
aria-label={t("current_duty.remaining_label")}
|
||||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("current_duty.remaining_label")}
|
||||
</span>
|
||||
<span className="text-xl font-semibold text-foreground tabular-nums" aria-hidden>
|
||||
{remainingValueStr}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("current_duty.ends_at", { time: formatHHMM(duty.end_at) })}
|
||||
</span>
|
||||
</div>
|
||||
<section className="flex flex-col gap-2 border-t border-border/50 pt-4" aria-label={t("contact.label")}>
|
||||
{hasContacts ? (
|
||||
<ContactLinks
|
||||
phone={duty.phone}
|
||||
username={duty.username}
|
||||
layout="block"
|
||||
showLabels={true}
|
||||
contextLabel={duty.full_name ?? undefined}
|
||||
showCopyButtons={true}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("current_duty.contact_info_not_set")}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button
|
||||
onClick={handlePrimaryAction}
|
||||
aria-label={primaryButtonAriaLabel}
|
||||
>
|
||||
{primaryButtonLabel}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
webapp-next/src/components/current-duty/index.ts
Normal file
2
webapp-next/src/components/current-duty/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CurrentDutyView } from "./CurrentDutyView";
|
||||
export type { CurrentDutyViewProps } from "./CurrentDutyView";
|
||||
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Unit tests for DayDetailContent: sorts duties by start_at; duty entries show time and name only (no contact links).
|
||||
* Ported from webapp/js/dayDetail.test.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string,
|
||||
extra: Partial<DutyWithUser> = {}
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
describe("DayDetailContent", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sorts duty list by start_at when input order is wrong", () => {
|
||||
const dateKey = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Петров", "2025-02-25T14:00:00Z", "2025-02-25T18:00:00Z", { id: 2 }),
|
||||
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T14:00:00Z", { id: 1 }),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||
expect(screen.getByText("Петров")).toBeInTheDocument();
|
||||
const body = document.body.innerHTML;
|
||||
const ivanovPos = body.indexOf("Иванов");
|
||||
const petrovPos = body.indexOf("Петров");
|
||||
expect(ivanovPos).toBeLessThan(petrovPos);
|
||||
});
|
||||
|
||||
it("shows duty time and name on one line and does not show contact links", () => {
|
||||
const dateKey = "2025-03-01";
|
||||
const duties = [
|
||||
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||
phone: "+79991234567",
|
||||
username: "alice_dev",
|
||||
}),
|
||||
];
|
||||
render(
|
||||
<DayDetailContent
|
||||
dateKey={dateKey}
|
||||
duties={duties}
|
||||
eventSummaries={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeNull();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeNull();
|
||||
expect(screen.queryByText(/alice_dev/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
258
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
258
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
|
||||
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
|
||||
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
/** Empty state for day detail: date and "no duties or events" message. */
|
||||
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0">
|
||||
{t("day_detail.no_events")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DayDetailHandle {
|
||||
/** Open the panel for the given day with popover anchored at the given rect. */
|
||||
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
export interface DayDetailProps {
|
||||
/** All duties for the visible range (will be filtered by selectedDay). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when the panel should close. */
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
|
||||
*/
|
||||
function VirtualAnchor({
|
||||
rect,
|
||||
className,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none fixed z-0", className)}
|
||||
style={{
|
||||
left: rect.left,
|
||||
top: rect.bottom,
|
||||
width: rect.width,
|
||||
height: 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const { t } = useTranslation();
|
||||
const selectedDay = useAppStore((s) => s.selectedDay);
|
||||
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
|
||||
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
||||
const [exiting, setExiting] = React.useState(false);
|
||||
|
||||
const open = selectedDay !== null;
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openWithRect(dateKey: string, rect: DOMRect) {
|
||||
setSelectedDay(dateKey);
|
||||
setAnchorRect(rect);
|
||||
},
|
||||
}),
|
||||
[setSelectedDay]
|
||||
);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
setAnchorRect(null);
|
||||
setExiting(false);
|
||||
onClose();
|
||||
}, [setSelectedDay, onClose]);
|
||||
|
||||
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
|
||||
const requestClose = React.useCallback(() => {
|
||||
setExiting(true);
|
||||
}, []);
|
||||
|
||||
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
|
||||
React.useEffect(() => {
|
||||
if (!exiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
handleClose();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [exiting, handleClose]);
|
||||
|
||||
const dutiesByDateMap = React.useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const eventsByDateMap = React.useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
|
||||
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
|
||||
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
|
||||
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
|
||||
|
||||
// Close popover/sheet on window resize so anchor position does not become stale.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => handleClose();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [open, handleClose]);
|
||||
|
||||
const content =
|
||||
selectedDay &&
|
||||
(hasContent ? (
|
||||
<DayDetailContent
|
||||
dateKey={selectedDay}
|
||||
duties={dayDuties}
|
||||
eventSummaries={dayEvents}
|
||||
/>
|
||||
) : (
|
||||
<DayDetailEmpty dateKey={selectedDay} />
|
||||
));
|
||||
|
||||
if (!open || !selectedDay) return null;
|
||||
|
||||
const panelClassName =
|
||||
"max-w-[min(360px,calc(100vw - var(--app-safe-left, 0) - var(--app-safe-right, 0) - 24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||
const closeButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={requestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderSheet = (withHandle: boolean) => (
|
||||
<Sheet
|
||||
open={!exiting && open}
|
||||
onOpenChange={(o) => !o && requestClose()}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]",
|
||||
className
|
||||
)}
|
||||
overlayClassName="backdrop-blur-md"
|
||||
showCloseButton={false}
|
||||
closeLabel={t("day_detail.close")}
|
||||
onCloseAnimationEnd={handleClose}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
{closeButton}
|
||||
{withHandle && (
|
||||
<div
|
||||
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<SheetHeader className="p-0">
|
||||
<SheetTitle id="day-detail-sheet-title" className="sr-only">
|
||||
{selectedDay}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
{content}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
if (isDesktop === true && anchorRect != null) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<PopoverAnchor asChild>
|
||||
<VirtualAnchor rect={anchorRect} />
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
align="center"
|
||||
className={cn(panelClassName, "relative", className)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={handleClose}
|
||||
>
|
||||
{closeButton}
|
||||
{content}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
return renderSheet(true);
|
||||
}
|
||||
);
|
||||
219
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
219
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Shared content for day detail: title and sections (duty, unavailable, vacation, events).
|
||||
* Ported from webapp/js/dayDetail.js buildDayDetailContent.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { getDutyMarkerRows } from "@/lib/duty-marker-rows";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NBSP = "\u00a0";
|
||||
|
||||
export interface DayDetailContentProps {
|
||||
/** YYYY-MM-DD key for the day. */
|
||||
dateKey: string;
|
||||
/** Duties overlapping this day. */
|
||||
duties: DutyWithUser[];
|
||||
/** Calendar event summary strings for this day. */
|
||||
eventSummaries: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DayDetailContent({
|
||||
dateKey,
|
||||
duties,
|
||||
eventSummaries,
|
||||
className,
|
||||
}: DayDetailContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
|
||||
const fromLabel = t("hint.from");
|
||||
const toLabel = t("hint.to");
|
||||
|
||||
const dutyList = useMemo(
|
||||
() =>
|
||||
duties
|
||||
.filter((d) => d.event_type === "duty")
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at || 0).getTime() -
|
||||
new Date(b.start_at || 0).getTime()
|
||||
),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const unavailableList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "unavailable"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const vacationList = useMemo(
|
||||
() => duties.filter((d) => d.event_type === "vacation"),
|
||||
[duties]
|
||||
);
|
||||
|
||||
const dutyRows = useMemo(() => {
|
||||
const hasTimes = dutyList.some((it) => it.start_at || it.end_at);
|
||||
return hasTimes
|
||||
? getDutyMarkerRows(dutyList, dateKey, NBSP, fromLabel, toLabel)
|
||||
: dutyList.map((d) => ({
|
||||
id: d.id,
|
||||
timePrefix: "",
|
||||
fullName: d.full_name ?? "",
|
||||
phone: d.phone,
|
||||
username: d.username,
|
||||
}));
|
||||
}, [dutyList, dateKey, fromLabel, toLabel]);
|
||||
|
||||
const uniqueUnavailable = useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
unavailableList.map((d) => d.full_name ?? "").filter(Boolean)
|
||||
),
|
||||
],
|
||||
[unavailableList]
|
||||
);
|
||||
|
||||
const uniqueVacation = useMemo(
|
||||
() => [
|
||||
...new Set(vacationList.map((d) => d.full_name ?? "").filter(Boolean)),
|
||||
],
|
||||
[vacationList]
|
||||
);
|
||||
|
||||
const summaries = eventSummaries ?? [];
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-4", className)}>
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{dutyList.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-duty"
|
||||
aria-labelledby="day-detail-duty-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-duty-heading"
|
||||
className="text-[0.8rem] font-semibold text-duty m-0 mb-1"
|
||||
>
|
||||
{t("event_type.duty")}
|
||||
</h3>
|
||||
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||
{dutyRows.map((r) => (
|
||||
<li key={r.id}>
|
||||
<span
|
||||
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||
aria-hidden
|
||||
>
|
||||
•
|
||||
</span>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
{r.timePrefix && (
|
||||
<span className="text-muted-foreground">{r.timePrefix} — </span>
|
||||
)}
|
||||
<span className="font-semibold">{r.fullName}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueUnavailable.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-unavailable"
|
||||
aria-labelledby="day-detail-unavailable-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-unavailable-heading"
|
||||
className="text-[0.8rem] font-semibold text-unavailable m-0 mb-1"
|
||||
>
|
||||
{t("event_type.unavailable")}
|
||||
</h3>
|
||||
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||
{uniqueUnavailable.map((name) => (
|
||||
<li key={name}>
|
||||
<span
|
||||
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||
aria-hidden
|
||||
>
|
||||
•
|
||||
</span>
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{uniqueVacation.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-vacation"
|
||||
aria-labelledby="day-detail-vacation-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-vacation-heading"
|
||||
className="text-[0.8rem] font-semibold text-vacation m-0 mb-1"
|
||||
>
|
||||
{t("event_type.vacation")}
|
||||
</h3>
|
||||
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||
{uniqueVacation.map((name) => (
|
||||
<li key={name}>
|
||||
<span
|
||||
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||
aria-hidden
|
||||
>
|
||||
•
|
||||
</span>
|
||||
{name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{summaries.length > 0 && (
|
||||
<section
|
||||
className="day-detail-section-events"
|
||||
aria-labelledby="day-detail-events-heading"
|
||||
>
|
||||
<h3
|
||||
id="day-detail-events-heading"
|
||||
className="text-[0.8rem] font-semibold text-accent m-0 mb-1"
|
||||
>
|
||||
{t("hint.events")}
|
||||
</h3>
|
||||
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||
{summaries.map((s) => (
|
||||
<li key={String(s)}>
|
||||
<span
|
||||
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||
aria-hidden
|
||||
>
|
||||
•
|
||||
</span>
|
||||
{String(s)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
webapp-next/src/components/day-detail/index.ts
Normal file
4
webapp-next/src/components/day-detail/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DayDetail } from "./DayDetail";
|
||||
export { DayDetailContent } from "./DayDetailContent";
|
||||
export type { DayDetailContentProps } from "./DayDetailContent";
|
||||
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";
|
||||
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Single duty row: event type label, name, time range.
|
||||
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface DutyItemProps {
|
||||
duty: DutyWithUser;
|
||||
/** Override type label (e.g. "On duty now"). */
|
||||
typeLabelOverride?: string;
|
||||
/** Show "until HH:MM" instead of full range (for current duty). */
|
||||
showUntilEnd?: boolean;
|
||||
/** Extra class, e.g. for current duty highlight. */
|
||||
isCurrent?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders type badge, name, and time. Timeline cards use event_type for border color.
|
||||
*/
|
||||
export function DutyItem({
|
||||
duty,
|
||||
typeLabelOverride,
|
||||
showUntilEnd = false,
|
||||
isCurrent = false,
|
||||
className,
|
||||
}: DutyItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const typeLabel =
|
||||
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
let timeOrRange: string;
|
||||
if (showUntilEnd && duty.event_type === "duty") {
|
||||
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
|
||||
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(duty.start_at);
|
||||
const endStr = formatDateKey(duty.end_at);
|
||||
timeOrRange = startStr === endStr ? startStr : `${startStr} – ${endStr}`;
|
||||
} else {
|
||||
timeOrRange = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
|
||||
"border-l-[3px] shadow-sm",
|
||||
"min-h-0",
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
className
|
||||
)}
|
||||
data-slot="duty-item"
|
||||
>
|
||||
<span className="text-xs text-muted col-span-1 row-start-1">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
|
||||
{timeOrRange}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user