Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.
|
||||||
53
.cursor/rules/frontend.mdc
Normal file
53
.cursor/rules/frontend.mdc
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
description: Rules for working with the Telegram Mini App frontend (webapp-next/)
|
||||||
|
globs:
|
||||||
|
- webapp-next/**
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontend — Telegram Mini App (Next.js)
|
||||||
|
|
||||||
|
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||||
|
- **TypeScript**
|
||||||
|
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||||
|
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||||
|
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||||
|
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
| Area | Location |
|
||||||
|
|------|----------|
|
||||||
|
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
|
||||||
|
| Providers | `src/components/providers/TelegramProvider.tsx` |
|
||||||
|
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
|
||||||
|
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||||
|
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||||
|
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||||
|
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||||
|
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||||
|
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||||
|
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
|
||||||
|
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
|
||||||
|
| Store | `src/store/app-store.ts` |
|
||||||
|
| Types | `src/types/index.ts` |
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||||
|
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||||
|
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||||
|
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
|
||||||
|
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||||
|
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||||
|
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||||
|
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||||
|
|
||||||
|
Consider these rules when changing the Mini App or adding frontend features.
|
||||||
113
.cursor/rules/project.mdc
Normal file
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
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.
|
# 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
|
# 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
|
# DEFAULT_LANGUAGE=en
|
||||||
|
|
||||||
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
# Reject Telegram initData older than this (seconds). 0 = do not check (default).
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install ruff bandit
|
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
|
- name: Lint with Ruff
|
||||||
run: |
|
run: |
|
||||||
ruff check duty_teller tests
|
ruff check duty_teller tests
|
||||||
@@ -39,14 +48,3 @@ jobs:
|
|||||||
- name: Security check with Bandit
|
- name: Security check with Bandit
|
||||||
run: |
|
run: |
|
||||||
bandit -r duty_teller -ll
|
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
|
*.pyo
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
.cursor/
|
|
||||||
.cursorrules/
|
|
||||||
# Test and coverage artifacts
|
# Test and coverage artifacts
|
||||||
.coverage
|
.coverage
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
*.cover
|
*.cover
|
||||||
*.plan.md
|
*.plan.md
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Next.js webapp
|
||||||
|
webapp-next/out/
|
||||||
|
webapp-next/node_modules/
|
||||||
|
webapp-next/.next/
|
||||||
51
AGENTS.md
Normal file
51
AGENTS.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Duty Teller — AI agent documentation
|
||||||
|
|
||||||
|
This file is for AI assistants (e.g. Cursor) and maintainers. All project documentation and docstrings must be in **English**. User-facing UI strings remain localized (ru/en) in `duty_teller/i18n/`.
|
||||||
|
|
||||||
|
## Project summary
|
||||||
|
|
||||||
|
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
|
||||||
|
|
||||||
|
## Key entry points
|
||||||
|
|
||||||
|
- **CLI / process:** `main.py` or, after `pip install -e .`, the `duty-teller` console command. Both delegate to `duty_teller.run.main()`.
|
||||||
|
- **Application setup:** `duty_teller/run.py` — builds the Telegram `Application`, registers handlers via `register_handlers(app)`, runs polling and FastAPI in a thread, calls `config.require_bot_token()` so the app exits clearly if `BOT_TOKEN` is missing.
|
||||||
|
- **HTTP API:** `duty_teller/api/app.py` — FastAPI app, route registration, static webapp mounted at `/app`.
|
||||||
|
|
||||||
|
## Where to change what
|
||||||
|
|
||||||
|
| Area | Location |
|
||||||
|
|------|----------|
|
||||||
|
| Telegram handlers | `duty_teller/handlers/` |
|
||||||
|
| REST API | `duty_teller/api/` |
|
||||||
|
| Business logic | `duty_teller/services/` |
|
||||||
|
| Database (models, repository, schemas) | `duty_teller/db/` |
|
||||||
|
| Translations (ru/en) | `duty_teller/i18n/` |
|
||||||
|
| Duty-schedule parser | `duty_teller/importers/` |
|
||||||
|
| Config (env vars) | `duty_teller/config.py` |
|
||||||
|
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||||
|
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||||
|
|
||||||
|
## Running and testing
|
||||||
|
|
||||||
|
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
|
||||||
|
- **Lint:** `ruff check duty_teller tests`
|
||||||
|
- **Security:** `bandit -r duty_teller -ll`
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||||
|
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||||
|
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||||
|
|
||||||
|
Docstrings and code comments must be in English (Google-style docstrings). UI strings are the only exception; they live in `duty_teller/i18n/`.
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Commits:** [Conventional Commits](https://www.conventionalcommits.org/) (e.g. `feat:`, `fix:`, `docs:`).
|
||||||
|
- **Branches:** Gitea Flow; changes via Pull Request.
|
||||||
|
- **Testing:** pytest, 80% coverage target; unit and integration tests.
|
||||||
|
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
||||||
|
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
||||||
|
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
||||||
|
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -7,9 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.0] - 2026-03-03
|
||||||
|
|
||||||
### Added
|
### 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
|
### Changed
|
||||||
|
|
||||||
@@ -36,4 +44,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Input validation and initData hash verification for Miniapp access.
|
- Input validation and initData hash verification for Miniapp access.
|
||||||
- Optional CORS and init_data_max_age; use env for secrets.
|
- Optional CORS and init_data_max_age; use env for secrets.
|
||||||
|
|
||||||
|
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
[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
|
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
|
## Commit messages
|
||||||
|
|
||||||
Use [Conventional Commits](https://www.conventionalcommits.org/), e.g.:
|
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.
|
# 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
|
FROM python:3.12-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY duty_teller/ ./duty_teller/
|
COPY duty_teller/ ./duty_teller/
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# --- Stage 2: runtime (minimal final image) ---
|
# --- Stage 3: runtime (minimal final image) ---
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
|||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
COPY duty_teller/ ./duty_teller/
|
COPY duty_teller/ ./duty_teller/
|
||||||
COPY alembic/ ./alembic/
|
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
|
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||||
RUN adduser --disabled-password --gecos "" 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.
|
- `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:
|
- `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.
|
- `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.
|
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
- `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.
|
- `utils/` – Shared date, user, and handover helpers.
|
||||||
- `importers/` – Duty-schedule JSON parser.
|
- `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`.
|
- `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.
|
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
- `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.
|
- **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.
|
- **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.
|
- **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
|
## Logs and rotation
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
|||||||
## Components
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -5,21 +5,23 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
|
|||||||
| Variable | Type / format | Default | Description |
|
| 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`. |
|
| **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_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_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`). |
|
| **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`. |
|
| **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. |
|
| **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`. |
|
| **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.** |
|
| **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` | Reject Telegram initData older than this many seconds. `0` = disabled. Example: `86400` for 24 hours. |
|
| **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. Example: `https://your-domain.com`. |
|
| **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. |
|
| **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_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. |
|
| **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
|
## Roles and access
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
|||||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
- [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 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.
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import re
|
|||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -43,6 +42,16 @@ def _is_valid_calendar_token(token: str) -> bool:
|
|||||||
app = FastAPI(title="Duty Teller API")
|
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")
|
@app.get("/health", summary="Health check")
|
||||||
def health() -> dict:
|
def health() -> dict:
|
||||||
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
"""Return 200 when the app is up. Used by Docker HEALTHCHECK."""
|
||||||
@@ -58,22 +67,102 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class NoCacheStaticMiddleware(BaseHTTPMiddleware):
|
class NoCacheStaticMiddleware:
|
||||||
"""Set Cache-Control for /app/*.js and /app/*.html so WebView gets fresh JS (i18n, etc.)."""
|
"""
|
||||||
|
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):
|
def __init__(self, app, **kwargs):
|
||||||
response = await call_next(request)
|
self.app = app
|
||||||
path = request.url.path
|
|
||||||
if path.startswith("/app/") and (
|
async def __call__(self, scope, receive, send):
|
||||||
path.endswith(".js") or path.endswith(".html")
|
if scope["type"] != "http":
|
||||||
):
|
await self.app(scope, receive, send)
|
||||||
response.headers["Cache-Control"] = "no-store"
|
return
|
||||||
return response
|
|
||||||
|
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)
|
app.add_middleware(NoCacheStaticMiddleware)
|
||||||
|
|
||||||
|
|
||||||
|
# Allowed values for config.js to prevent script injection.
|
||||||
|
_VALID_LANGS = frozenset({"en", "ru"})
|
||||||
|
_VALID_LOG_LEVELS = frozenset({"debug", "info", "warning", "error"})
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||||
|
"""Return value if it is in allowed set, else default. Prevents injection in config.js."""
|
||||||
|
if value in allowed:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||||
|
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_tz_string(value: str) -> str:
|
||||||
|
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||||
|
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/app/config.js",
|
||||||
|
summary="Mini App config (language, log level, timezone)",
|
||||||
|
description=(
|
||||||
|
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
|
||||||
|
"Loaded before main.js."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def app_config_js() -> Response:
|
||||||
|
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
|
||||||
|
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||||
|
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||||
|
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||||
|
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||||
|
body = (
|
||||||
|
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
content=body,
|
||||||
|
media_type="application/javascript; charset=utf-8",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/api/duties",
|
"/api/duties",
|
||||||
response_model=list[DutyWithUser],
|
response_model=list[DutyWithUser],
|
||||||
@@ -132,10 +221,10 @@ def get_team_calendar_ical(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
"""Return ICS calendar with all duties (event_type duty only). Token validates user."""
|
||||||
if not _is_valid_calendar_token(token):
|
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)
|
user = get_user_by_calendar_token(session, token)
|
||||||
if user is None:
|
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",)
|
cache_key = ("team_ics",)
|
||||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||||
if not found:
|
if not found:
|
||||||
@@ -173,10 +262,10 @@ def get_personal_calendar_ical(
|
|||||||
No Telegram auth; access is by secret token in the URL.
|
No Telegram auth; access is by secret token in the URL.
|
||||||
"""
|
"""
|
||||||
if not _is_valid_calendar_token(token):
|
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)
|
user = get_user_by_calendar_token(session, token)
|
||||||
if user is None:
|
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)
|
cache_key = ("personal_ics", user.id)
|
||||||
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
ics_bytes, found = ics_calendar_cache.get(cache_key)
|
||||||
if not found:
|
if not found:
|
||||||
@@ -194,6 +283,6 @@ def get_personal_calendar_ical(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||||
if webapp_path.is_dir():
|
if webapp_path.is_dir():
|
||||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
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."""
|
"""FastAPI dependencies: DB session, Miniapp auth (initData/allowlist), date validation."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import Annotated, Generator
|
from typing import Annotated, Generator
|
||||||
|
|
||||||
from fastapi import Depends, Header, HTTPException, Query, Request
|
from fastapi import Depends, Header, HTTPException, Query, Request
|
||||||
@@ -17,42 +16,18 @@ from duty_teller.db.repository import (
|
|||||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||||
from duty_teller.db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
from duty_teller.i18n import t
|
from duty_teller.i18n import t
|
||||||
from duty_teller.i18n.lang import normalize_lang
|
|
||||||
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
from duty_teller.utils.dates import DateRangeValidationError, validate_date_range
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
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:
|
The header argument is kept for backward compatibility but is ignored.
|
||||||
header: Raw Accept-Language header value (e.g. "ru-RU,ru;q=0.9,en;q=0.8").
|
The whole deployment uses a single language from DEFAULT_LANGUAGE.
|
||||||
|
|
||||||
Returns:
|
|
||||||
'ru' or 'en'.
|
|
||||||
"""
|
"""
|
||||||
code = _parse_first_language_code(header)
|
return config.DEFAULT_LANGUAGE
|
||||||
return normalize_lang(code if code is not None else config.DEFAULT_LANGUAGE)
|
|
||||||
|
|
||||||
|
|
||||||
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
def _auth_error_detail(auth_reason: str, lang: str) -> str:
|
||||||
@@ -67,7 +42,12 @@ def _validate_duty_dates(from_date: str, to_date: str, lang: str) -> None:
|
|||||||
try:
|
try:
|
||||||
validate_date_range(from_date, to_date)
|
validate_date_range(from_date, to_date)
|
||||||
except DateRangeValidationError as e:
|
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
|
raise HTTPException(status_code=400, detail=t(lang, key)) from e
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
# Backward compatibility if something else raises ValueError.
|
# Backward compatibility if something else raises ValueError.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import json
|
|||||||
import time
|
import time
|
||||||
from urllib.parse import unquote
|
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
|
# 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.
|
# 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:
|
Returns:
|
||||||
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
Tuple (telegram_user_id, username, reason, lang). reason is one of: "ok",
|
||||||
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
"empty", "no_hash", "hash_mismatch", "auth_date_expired", "no_user",
|
||||||
"user_invalid", "no_user_id". lang is from user.language_code normalized
|
"user_invalid", "no_user_id". lang is always config.DEFAULT_LANGUAGE.
|
||||||
to 'ru' or 'en'; 'en' when no user. On success: (user.id, username or None,
|
On success: (user.id, username or None, "ok", lang).
|
||||||
"ok", lang).
|
|
||||||
"""
|
"""
|
||||||
|
lang = config.DEFAULT_LANGUAGE
|
||||||
if not init_data or not bot_token:
|
if not init_data or not bot_token:
|
||||||
return (None, None, "empty", "en")
|
return (None, None, "empty", lang)
|
||||||
init_data = init_data.strip()
|
init_data = init_data.strip()
|
||||||
params = {}
|
params = {}
|
||||||
for part in init_data.split("&"):
|
for part in init_data.split("&"):
|
||||||
@@ -65,7 +65,7 @@ def validate_init_data_with_reason(
|
|||||||
params[key] = value
|
params[key] = value
|
||||||
hash_val = params.pop("hash", None)
|
hash_val = params.pop("hash", None)
|
||||||
if not hash_val:
|
if not hash_val:
|
||||||
return (None, None, "no_hash", "en")
|
return (None, None, "no_hash", lang)
|
||||||
data_pairs = sorted(params.items())
|
data_pairs = sorted(params.items())
|
||||||
# Data-check string: key=value with URL-decoded values (per Telegram example)
|
# 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)
|
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,
|
digestmod=hashlib.sha256,
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
if not hmac.compare_digest(computed.lower(), hash_val.lower()):
|
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:
|
if max_age_seconds is not None and max_age_seconds > 0:
|
||||||
auth_date_raw = params.get("auth_date")
|
auth_date_raw = params.get("auth_date")
|
||||||
if not auth_date_raw:
|
if not auth_date_raw:
|
||||||
return (None, None, "auth_date_expired", "en")
|
return (None, None, "auth_date_expired", lang)
|
||||||
try:
|
try:
|
||||||
auth_date = int(float(auth_date_raw))
|
auth_date = int(float(auth_date_raw))
|
||||||
except (ValueError, TypeError):
|
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:
|
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")
|
user_raw = params.get("user")
|
||||||
if not user_raw:
|
if not user_raw:
|
||||||
return (None, None, "no_user", "en")
|
return (None, None, "no_user", lang)
|
||||||
try:
|
try:
|
||||||
user = json.loads(unquote(user_raw))
|
user = json.loads(unquote(user_raw))
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return (None, None, "user_invalid", "en")
|
return (None, None, "user_invalid", lang)
|
||||||
if not isinstance(user, dict):
|
if not isinstance(user, dict):
|
||||||
return (None, None, "user_invalid", "en")
|
return (None, None, "user_invalid", lang)
|
||||||
lang = normalize_lang(user.get("language_code"))
|
|
||||||
raw_id = user.get("id")
|
raw_id = user.get("id")
|
||||||
if raw_id is None:
|
if raw_id is None:
|
||||||
return (None, None, "no_user_id", lang)
|
return (None, None, "no_user_id", lang)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Load configuration from environment (e.g. .env via python-dotenv).
|
"""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
|
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 os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -15,6 +17,14 @@ from duty_teller.i18n.lang import normalize_lang
|
|||||||
|
|
||||||
load_dotenv()
|
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 (parent of duty_teller package). Used for webapp path, etc.
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
@@ -46,6 +56,56 @@ def _parse_phone_list(raw: str) -> set[str]:
|
|||||||
return result
|
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)
|
@dataclass(frozen=True)
|
||||||
class Settings:
|
class Settings:
|
||||||
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
"""Injectable settings built from environment. Used in tests or when env is overridden."""
|
||||||
@@ -54,6 +114,7 @@ class Settings:
|
|||||||
database_url: str
|
database_url: str
|
||||||
bot_username: str
|
bot_username: str
|
||||||
mini_app_base_url: str
|
mini_app_base_url: str
|
||||||
|
mini_app_short_name: str
|
||||||
http_host: str
|
http_host: str
|
||||||
http_port: int
|
http_port: int
|
||||||
allowed_usernames: set[str]
|
allowed_usernames: set[str]
|
||||||
@@ -67,6 +128,7 @@ class Settings:
|
|||||||
duty_display_tz: str
|
duty_display_tz: str
|
||||||
default_language: str
|
default_language: str
|
||||||
duty_pin_notify: bool
|
duty_pin_notify: bool
|
||||||
|
log_level: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls) -> "Settings":
|
def from_env(cls) -> "Settings":
|
||||||
@@ -95,20 +157,33 @@ class Settings:
|
|||||||
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
raw_host = (os.getenv("HTTP_HOST") or "127.0.0.1").strip()
|
||||||
http_host = raw_host if raw_host else "127.0.0.1"
|
http_host = raw_host if raw_host else "127.0.0.1"
|
||||||
bot_username = (os.getenv("BOT_USERNAME", "") or "").strip().lstrip("@").lower()
|
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(
|
return cls(
|
||||||
bot_token=bot_token,
|
bot_token=bot_token,
|
||||||
database_url=os.getenv("DATABASE_URL", "sqlite:///data/duty_teller.db"),
|
database_url=database_url,
|
||||||
bot_username=bot_username,
|
bot_username=bot_username,
|
||||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
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_host=http_host,
|
||||||
http_port=int(os.getenv("HTTP_PORT", "8080")),
|
http_port=http_port,
|
||||||
allowed_usernames=allowed,
|
allowed_usernames=allowed,
|
||||||
admin_usernames=admin,
|
admin_usernames=admin,
|
||||||
allowed_phones=allowed_phones,
|
allowed_phones=allowed_phones,
|
||||||
admin_phones=admin_phones,
|
admin_phones=admin_phones,
|
||||||
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
mini_app_skip_auth=os.getenv("MINI_APP_SKIP_AUTH", "").strip()
|
||||||
in ("1", "true", "yes"),
|
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,
|
cors_origins=cors,
|
||||||
external_calendar_ics_url=os.getenv(
|
external_calendar_ics_url=os.getenv(
|
||||||
"EXTERNAL_CALENDAR_ICS_URL", ""
|
"EXTERNAL_CALENDAR_ICS_URL", ""
|
||||||
@@ -118,6 +193,7 @@ class Settings:
|
|||||||
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
default_language=normalize_lang(os.getenv("DEFAULT_LANGUAGE", "en")),
|
||||||
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
|
duty_pin_notify=os.getenv("DUTY_PIN_NOTIFY", "1").strip().lower()
|
||||||
not in ("0", "false", "no"),
|
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
|
DATABASE_URL = _settings.database_url
|
||||||
BOT_USERNAME = _settings.bot_username
|
BOT_USERNAME = _settings.bot_username
|
||||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||||
|
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||||
HTTP_HOST = _settings.http_host
|
HTTP_HOST = _settings.http_host
|
||||||
HTTP_PORT = _settings.http_port
|
HTTP_PORT = _settings.http_port
|
||||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
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
|
DUTY_DISPLAY_TZ = _settings.duty_display_tz
|
||||||
DEFAULT_LANGUAGE = _settings.default_language
|
DEFAULT_LANGUAGE = _settings.default_language
|
||||||
DUTY_PIN_NOTIFY = _settings.duty_pin_notify
|
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:
|
def is_admin(username: str) -> bool:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, timezone
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
|
from duty_teller.db.schemas import DUTY_EVENT_TYPES
|
||||||
from duty_teller.db.models import (
|
from duty_teller.db.models import (
|
||||||
User,
|
User,
|
||||||
Duty,
|
Duty,
|
||||||
@@ -201,14 +202,19 @@ def get_or_create_user(
|
|||||||
return 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).
|
"""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.
|
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:
|
Args:
|
||||||
session: DB session.
|
session: DB session.
|
||||||
full_name: Exact full name to match or set.
|
full_name: Exact full name to match or set.
|
||||||
|
commit: If True, commit immediately. If False, caller commits.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User instance (existing or newly created).
|
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,
|
name_manually_edited=True,
|
||||||
)
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
session.commit()
|
if commit:
|
||||||
session.refresh(user)
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
else:
|
||||||
|
session.flush() # Assign id so caller can use user.id before commit
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -447,11 +456,13 @@ def insert_duty(
|
|||||||
user_id: User id.
|
user_id: User id.
|
||||||
start_at: Start time UTC, ISO 8601 with Z (e.g. 2025-01-15T09:00:00Z).
|
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.
|
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:
|
Returns:
|
||||||
Created Duty instance.
|
Created Duty instance.
|
||||||
"""
|
"""
|
||||||
|
if event_type not in DUTY_EVENT_TYPES:
|
||||||
|
event_type = "duty"
|
||||||
duty = Duty(
|
duty = Duty(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
start_at=start_at,
|
start_at=start_at,
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ async def set_phone(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
|||||||
phone = " ".join(args).strip() if args else None
|
phone = " ".join(args).strip() if args else None
|
||||||
telegram_user_id = update.effective_user.id
|
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:
|
with session_scope(config.DATABASE_URL) as session:
|
||||||
full_name = build_full_name(
|
full_name = build_full_name(
|
||||||
update.effective_user.first_name, update.effective_user.last_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)
|
user = set_user_phone(session, telegram_user_id, phone or None)
|
||||||
if user is None:
|
if user is None:
|
||||||
return "error"
|
return ("error", None)
|
||||||
if phone:
|
if phone:
|
||||||
return "saved"
|
return ("saved", user.phone or config.normalize_phone(phone))
|
||||||
return "cleared"
|
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":
|
if result == "error":
|
||||||
await update.message.reply_text(t(lang, "set_phone.error"))
|
await update.message.reply_text(t(lang, "set_phone.error"))
|
||||||
elif result == "saved":
|
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:
|
else:
|
||||||
await update.message.reply_text(t(lang, "set_phone.cleared"))
|
await update.message.reply_text(t(lang, "set_phone.cleared"))
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
import random
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
@@ -29,6 +30,10 @@ from duty_teller.services.group_duty_pin_service import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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_"
|
JOB_NAME_PREFIX = "duty_pin_"
|
||||||
RETRY_WHEN_NO_DUTY_MINUTES = 15
|
RETRY_WHEN_NO_DUTY_MINUTES = 15
|
||||||
|
|
||||||
@@ -87,10 +92,19 @@ def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
|||||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
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
|
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
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:
|
if not config.BOT_USERNAME:
|
||||||
return None
|
return None
|
||||||
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
short = (config.MINI_APP_SHORT_NAME or "").strip().strip("/")
|
||||||
|
if short:
|
||||||
|
url = f"https://t.me/{config.BOT_USERNAME}/{short}?startapp=duty"
|
||||||
|
else:
|
||||||
|
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||||
button = InlineKeyboardButton(
|
button = InlineKeyboardButton(
|
||||||
text=t(lang, "pin_duty.view_contacts"),
|
text=t(lang, "pin_duty.view_contacts"),
|
||||||
url=url,
|
url=url,
|
||||||
@@ -115,8 +129,12 @@ def _sync_untrust_group(chat_id: int) -> tuple[bool, int | None]:
|
|||||||
|
|
||||||
|
|
||||||
async def _schedule_next_update(
|
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:
|
) -> None:
|
||||||
|
"""Schedule the next pin refresh job. Optional jitter spreads jobs when scheduling many chats."""
|
||||||
job_queue = application.job_queue
|
job_queue = application.job_queue
|
||||||
if job_queue is None:
|
if job_queue is None:
|
||||||
logger.warning("Job queue not available, cannot schedule pin update")
|
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:
|
if when_utc is not None:
|
||||||
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
now_utc = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
delay = when_utc - now_utc
|
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:
|
if delay.total_seconds() < 1:
|
||||||
delay = 1
|
delay = timedelta(seconds=1)
|
||||||
job_queue.run_once(
|
job_queue.run_once(
|
||||||
update_group_pin,
|
update_group_pin,
|
||||||
when=delay,
|
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)
|
logger.info("Scheduled pin update for chat_id=%s at %s", chat_id, when_utc)
|
||||||
else:
|
else:
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
job_queue.run_once(
|
job_queue.run_once(
|
||||||
update_group_pin,
|
update_group_pin,
|
||||||
when=timedelta(minutes=RETRY_WHEN_NO_DUTY_MINUTES),
|
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).
|
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".
|
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:
|
Returns:
|
||||||
"updated" if the message was sent, pinned and saved successfully;
|
"updated" if the message was sent, pinned and saved successfully;
|
||||||
"no_message" if there is no pin record for this chat;
|
"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).
|
"untrusted" if the group was removed from trusted list (pin record and message cleaned up).
|
||||||
"""
|
"""
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -196,42 +216,59 @@ async def _refresh_pin_for_chat(
|
|||||||
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
logger.info("No pin record for chat_id=%s, skipping update", chat_id)
|
||||||
return "no_message"
|
return "no_message"
|
||||||
old_message_id = message_id
|
old_message_id = message_id
|
||||||
|
|
||||||
|
async with _lock_for_refresh_locks:
|
||||||
|
lock = _refresh_locks.setdefault(chat_id, asyncio.Lock())
|
||||||
try:
|
try:
|
||||||
msg = await context.bot.send_message(
|
async with lock:
|
||||||
chat_id=chat_id,
|
try:
|
||||||
text=text,
|
msg = await context.bot.send_message(
|
||||||
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
|
chat_id=chat_id,
|
||||||
)
|
text=text,
|
||||||
except (BadRequest, Forbidden) as e:
|
reply_markup=_get_contact_button_markup(config.DEFAULT_LANGUAGE),
|
||||||
logger.warning(
|
)
|
||||||
"Failed to send duty message for pin refresh chat_id=%s: %s", chat_id, e
|
except (BadRequest, Forbidden) as e:
|
||||||
)
|
logger.warning(
|
||||||
await _schedule_next_update(context.application, chat_id, next_end)
|
"Failed to send duty message for pin refresh chat_id=%s: %s",
|
||||||
return "failed"
|
chat_id,
|
||||||
try:
|
e,
|
||||||
await context.bot.unpin_chat_message(chat_id=chat_id)
|
)
|
||||||
await context.bot.pin_chat_message(
|
await _schedule_next_update(context.application, chat_id, next_end)
|
||||||
chat_id=chat_id,
|
return "failed"
|
||||||
message_id=msg.message_id,
|
try:
|
||||||
disable_notification=not config.DUTY_PIN_NOTIFY,
|
await context.bot.unpin_chat_message(chat_id=chat_id)
|
||||||
)
|
except (BadRequest, Forbidden) as e:
|
||||||
except (BadRequest, Forbidden) as e:
|
logger.debug(
|
||||||
logger.warning("Unpin or pin after refresh failed chat_id=%s: %s", chat_id, e)
|
"Unpin failed (e.g. no pinned message) chat_id=%s: %s", chat_id, e
|
||||||
await _schedule_next_update(context.application, chat_id, next_end)
|
)
|
||||||
return "failed"
|
try:
|
||||||
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
await context.bot.pin_chat_message(
|
||||||
if old_message_id is not None:
|
chat_id=chat_id,
|
||||||
try:
|
message_id=msg.message_id,
|
||||||
await context.bot.delete_message(chat_id=chat_id, message_id=old_message_id)
|
disable_notification=not config.DUTY_PIN_NOTIFY,
|
||||||
except (BadRequest, Forbidden) as e:
|
)
|
||||||
logger.warning(
|
except (BadRequest, Forbidden) as e:
|
||||||
"Could not delete old pinned message %s in chat_id=%s: %s",
|
logger.warning("Pin after refresh failed chat_id=%s: %s", chat_id, e)
|
||||||
old_message_id,
|
await _schedule_next_update(context.application, chat_id, next_end)
|
||||||
chat_id,
|
return "failed"
|
||||||
e,
|
await loop.run_in_executor(None, _sync_save_pin, chat_id, msg.message_id)
|
||||||
)
|
if old_message_id is not None:
|
||||||
await _schedule_next_update(context.application, chat_id, next_end)
|
try:
|
||||||
return "updated"
|
await context.bot.delete_message(
|
||||||
|
chat_id=chat_id, message_id=old_message_id
|
||||||
|
)
|
||||||
|
except (BadRequest, Forbidden) as e:
|
||||||
|
logger.warning(
|
||||||
|
"Could not delete old pinned message %s in chat_id=%s: %s",
|
||||||
|
old_message_id,
|
||||||
|
chat_id,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
await _schedule_next_update(context.application, chat_id, next_end)
|
||||||
|
return "updated"
|
||||||
|
finally:
|
||||||
|
async with _lock_for_refresh_locks:
|
||||||
|
_refresh_locks.pop(chat_id, None)
|
||||||
|
|
||||||
|
|
||||||
async def update_group_pin(context: ContextTypes.DEFAULT_TYPE) -> None:
|
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:
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
chat_ids = await loop.run_in_executor(None, _get_all_pin_chat_ids_sync)
|
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)
|
next_end = await loop.run_in_executor(None, _get_next_shift_end_sync)
|
||||||
for chat_id in chat_ids:
|
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))
|
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,
|
message_id=message_id,
|
||||||
disable_notification=True,
|
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"))
|
await update.message.reply_text(t(lang, "pin_duty.pinned"))
|
||||||
except (BadRequest, Forbidden) as e:
|
except (BadRequest, Forbidden) as e:
|
||||||
logger.warning("pin_duty failed chat_id=%s: %s", chat_id, 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 duty-schedule: /import_duty_schedule (admin only). Two steps: handover time -> JSON file."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
from telegram import Update
|
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.services.import_service import run_import
|
||||||
from duty_teller.utils.handover import parse_handover_time
|
from duty_teller.utils.handover import parse_handover_time
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def import_duty_schedule_cmd(
|
async def import_duty_schedule_cmd(
|
||||||
update: Update, context: ContextTypes.DEFAULT_TYPE
|
update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||||
@@ -80,9 +83,10 @@ async def handle_duty_schedule_document(
|
|||||||
try:
|
try:
|
||||||
result = parse_duty_schedule(raw)
|
result = parse_duty_schedule(raw)
|
||||||
except DutyScheduleParseError as e:
|
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("awaiting_duty_schedule_file", None)
|
||||||
context.user_data.pop("handover_utc_time", 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
|
return
|
||||||
|
|
||||||
def run_import_with_scope():
|
def run_import_with_scope():
|
||||||
@@ -95,7 +99,8 @@ async def handle_duty_schedule_document(
|
|||||||
None, run_import_with_scope
|
None, run_import_with_scope
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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:
|
else:
|
||||||
total = num_duty + num_unavailable + num_vacation
|
total = num_duty + num_unavailable + num_vacation
|
||||||
unavailable_suffix = (
|
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
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
from duty_teller.i18n.lang import normalize_lang
|
|
||||||
from duty_teller.i18n.messages import MESSAGES
|
from duty_teller.i18n.messages import MESSAGES
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -12,13 +11,12 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
def get_lang(user: "User | None") -> str:
|
def get_lang(user: "User | None") -> str:
|
||||||
"""
|
"""
|
||||||
Normalize Telegram user language to 'ru' or 'en'.
|
Return the application language: always config.DEFAULT_LANGUAGE.
|
||||||
Uses normalize_lang for user.language_code; when user is None or has no
|
|
||||||
language_code, returns 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 config.DEFAULT_LANGUAGE
|
|
||||||
return normalize_lang(user.language_code)
|
|
||||||
|
|
||||||
|
|
||||||
def t(lang: str, key: str, **kwargs: str) -> str:
|
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.send_json": "Send the duty-schedule file (JSON).",
|
||||||
"import.need_json": "File must have .json extension.",
|
"import.need_json": "File must have .json extension.",
|
||||||
"import.parse_error": "File parse error: {error}",
|
"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": "Import error: {error}",
|
||||||
|
"import.import_error_generic": "Import failed. Please try again or contact an administrator.",
|
||||||
"import.done": (
|
"import.done": (
|
||||||
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
|
"Import done: {users} users, {duties} duties{unavailable}{vacation} "
|
||||||
"({total} events total)."
|
"({total} events total)."
|
||||||
@@ -88,6 +90,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"api.access_denied": "Access denied",
|
"api.access_denied": "Access denied",
|
||||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
"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.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.show": "Contacts",
|
||||||
"contact.back": "Back",
|
"contact.back": "Back",
|
||||||
"current_duty.title": "Current Duty",
|
"current_duty.title": "Current Duty",
|
||||||
@@ -160,7 +163,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
|
"import.send_json": "Отправьте файл в формате duty-schedule (JSON).",
|
||||||
"import.need_json": "Нужен файл с расширением .json",
|
"import.need_json": "Нужен файл с расширением .json",
|
||||||
"import.parse_error": "Ошибка разбора файла: {error}",
|
"import.parse_error": "Ошибка разбора файла: {error}",
|
||||||
|
"import.parse_error_generic": "Не удалось разобрать файл. Проверьте формат и попробуйте снова.",
|
||||||
"import.import_error": "Ошибка импорта: {error}",
|
"import.import_error": "Ошибка импорта: {error}",
|
||||||
|
"import.import_error_generic": "Импорт не выполнен. Попробуйте снова или обратитесь к администратору.",
|
||||||
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
|
"import.done": "Импорт выполнен: {users} пользователей, {duties} дежурств{unavailable}{vacation} (всего {total} событий).",
|
||||||
"import.done_unavailable": ", {count} недоступностей",
|
"import.done_unavailable": ", {count} недоступностей",
|
||||||
"import.done_vacation": ", {count} отпусков",
|
"import.done_vacation": ", {count} отпусков",
|
||||||
@@ -171,6 +176,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"api.access_denied": "Доступ запрещён",
|
"api.access_denied": "Доступ запрещён",
|
||||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||||
|
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||||
"contact.show": "Контакты",
|
"contact.show": "Контакты",
|
||||||
"contact.back": "Назад",
|
"contact.back": "Назад",
|
||||||
"current_duty.title": "Текущее дежурство",
|
"current_duty.title": "Текущее дежурство",
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ DUTY_MARKERS = frozenset({"б", "Б", "в", "В"})
|
|||||||
UNAVAILABLE_MARKER = "Н"
|
UNAVAILABLE_MARKER = "Н"
|
||||||
VACATION_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
|
@dataclass
|
||||||
class DutyScheduleEntry:
|
class DutyScheduleEntry:
|
||||||
@@ -69,10 +74,24 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise DutyScheduleParseError(f"Invalid meta.start_date: {start_str}") from 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")
|
schedule = data.get("schedule")
|
||||||
if not isinstance(schedule, list):
|
if not isinstance(schedule, list):
|
||||||
raise DutyScheduleParseError("Missing or invalid 'schedule' (must be array)")
|
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
|
max_days = 0
|
||||||
entries: list[DutyScheduleEntry] = []
|
entries: list[DutyScheduleEntry] = []
|
||||||
|
|
||||||
@@ -85,12 +104,20 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
|||||||
full_name = name.strip()
|
full_name = name.strip()
|
||||||
if not full_name:
|
if not full_name:
|
||||||
raise DutyScheduleParseError("schedule item 'name' cannot be empty")
|
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")
|
duty_str = row.get("duty")
|
||||||
if duty_str is None:
|
if duty_str is None:
|
||||||
duty_str = ""
|
duty_str = ""
|
||||||
if not isinstance(duty_str, str):
|
if not isinstance(duty_str, str):
|
||||||
raise DutyScheduleParseError("schedule item 'duty' must be string")
|
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(";")]
|
cells = [c.strip() for c in duty_str.split(";")]
|
||||||
max_days = max(max_days, len(cells))
|
max_days = max(max_days, len(cells))
|
||||||
@@ -120,4 +147,9 @@ def parse_duty_schedule(raw_bytes: bytes) -> DutyScheduleResult:
|
|||||||
else:
|
else:
|
||||||
end_date = start_date + timedelta(days=max_days - 1)
|
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)
|
return DutyScheduleResult(start_date=start_date, end_date=end_date, entries=entries)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from telegram.ext import ApplicationBuilder
|
from telegram.ext import ApplicationBuilder
|
||||||
@@ -13,6 +15,9 @@ from duty_teller.config import require_bot_token
|
|||||||
from duty_teller.handlers import group_duty_pin, register_handlers
|
from duty_teller.handlers import group_duty_pin, register_handlers
|
||||||
from duty_teller.utils.http_client import safe_urlopen
|
from duty_teller.utils.http_client import safe_urlopen
|
||||||
|
|
||||||
|
# Seconds to wait for HTTP server to bind before health check.
|
||||||
|
_HTTP_STARTUP_WAIT_SEC = 3
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_bot_username(application) -> None:
|
async def _resolve_bot_username(application) -> None:
|
||||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||||
@@ -24,7 +29,7 @@ async def _resolve_bot_username(application) -> None:
|
|||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
level=logging.INFO,
|
level=config.LOG_LEVEL,
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,6 +74,25 @@ def _run_uvicorn(web_app, port: int) -> None:
|
|||||||
loop.run_until_complete(server.serve())
|
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:
|
def main() -> None:
|
||||||
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
|
"""Build the bot and FastAPI, start uvicorn in a thread, run polling."""
|
||||||
require_bot_token()
|
require_bot_token()
|
||||||
@@ -85,16 +109,30 @@ def main() -> None:
|
|||||||
|
|
||||||
from duty_teller.api.app import app as web_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:
|
if config.MINI_APP_SKIP_AUTH:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"MINI_APP_SKIP_AUTH is set — API auth disabled (insecure); use only for dev"
|
"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)
|
logger.info("Bot starting (polling)... HTTP API on port %s", config.HTTP_PORT)
|
||||||
app.run_polling(allowed_updates=["message", "my_chat_member"])
|
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 duty schedule: delete range, insert duties/unavailable/vacation. Accepts session."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
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.importers.duty_schedule import DutyScheduleResult
|
||||||
from duty_teller.utils.dates import day_start_iso, day_end_iso, duty_to_iso
|
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]]:
|
def _consecutive_date_ranges(dates: list[date]) -> list[tuple[date, date]]:
|
||||||
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
"""Sort dates and merge consecutive ones into (first, last) ranges. Empty list -> []."""
|
||||||
@@ -53,16 +56,24 @@ def run_import(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple (num_users, num_duty, num_unavailable, num_vacation).
|
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()
|
from_date_str = result.start_date.isoformat()
|
||||||
to_date_str = result.end_date.isoformat()
|
to_date_str = result.end_date.isoformat()
|
||||||
num_duty = num_unavailable = num_vacation = 0
|
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]
|
names = [e.full_name for e in result.entries]
|
||||||
users_map = get_users_by_full_names(session, names)
|
users_map = get_users_by_full_names(session, names)
|
||||||
for name in names:
|
for name in names:
|
||||||
if name not in users_map:
|
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)
|
# Delete range per user (no commit)
|
||||||
for entry in result.entries:
|
for entry in result.entries:
|
||||||
@@ -113,4 +124,11 @@ def run_import(
|
|||||||
session.bulk_insert_mappings(Duty, duty_rows)
|
session.bulk_insert_mappings(Duty, duty_rows)
|
||||||
session.commit()
|
session.commit()
|
||||||
invalidate_duty_related_caches()
|
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)
|
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}$")
|
_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):
|
class DateRangeValidationError(ValueError):
|
||||||
"""Raised when from_date/to_date validation fails. API uses kind for i18n key."""
|
"""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
|
self.kind = kind
|
||||||
super().__init__(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:
|
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:
|
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 ""):
|
if not _ISO_DATE_RE.match(from_date or "") or not _ISO_DATE_RE.match(to_date or ""):
|
||||||
raise DateRangeValidationError("bad_format")
|
raise DateRangeValidationError("bad_format")
|
||||||
if from_date > to_date:
|
if from_date > to_date:
|
||||||
raise DateRangeValidationError("from_after_to")
|
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")
|
||||||
|
|||||||
@@ -10,24 +10,29 @@ import duty_teller.config as config
|
|||||||
|
|
||||||
|
|
||||||
class TestLangFromAcceptLanguage:
|
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
|
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(" ") == 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):
|
def test_returns_ru_when_default_language_is_ru(self):
|
||||||
assert deps._lang_from_accept_language("ru-RU,ru;q=0.9") == "ru"
|
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):
|
def test_returns_en_when_default_language_is_en(self):
|
||||||
assert deps._lang_from_accept_language("en-US") == "en"
|
with patch.object(config, "DEFAULT_LANGUAGE", "en"):
|
||||||
|
assert deps._lang_from_accept_language("ru-RU") == "en"
|
||||||
def test_invalid_fallback_to_en(self):
|
assert deps._lang_from_accept_language(None) == "en"
|
||||||
assert deps._lang_from_accept_language("zz") == "en"
|
|
||||||
assert deps._lang_from_accept_language("x") == "en"
|
|
||||||
|
|
||||||
|
|
||||||
class TestAuthErrorDetail:
|
class TestAuthErrorDetail:
|
||||||
|
|||||||
@@ -23,6 +23,80 @@ def test_health(client):
|
|||||||
assert r.json() == {"status": "ok"}
|
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):
|
def test_duties_invalid_date_format(client):
|
||||||
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
r = client.get("/api/duties", params={"from": "2025-01-01", "to": "invalid"})
|
||||||
assert r.status_code == 400
|
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
|
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)
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
def test_duties_403_without_init_data(client):
|
def test_duties_403_without_init_data(client):
|
||||||
"""Without X-Telegram-Init-Data and without MINI_APP_SKIP_AUTH → 403 (any 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):
|
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")
|
r = client.get("/api/calendar/ical/team/short.ics")
|
||||||
assert r.status_code == 404
|
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")
|
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||||
def test_calendar_ical_team_404_unknown_token(mock_get_user, client):
|
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
|
mock_get_user.return_value = None
|
||||||
valid_format_token = "B" * 43
|
valid_format_token = "B" * 43
|
||||||
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
r = client.get(f"/api/calendar/ical/team/{valid_format_token}.ics")
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
assert "not found" in r.text.lower()
|
assert r.json() == {"detail": "Not found"}
|
||||||
mock_get_user.assert_called_once()
|
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):
|
def test_calendar_ical_404_invalid_token_format(client):
|
||||||
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 without DB call."""
|
"""GET /api/calendar/ical/{token}.ics with invalid token format returns 404 JSON."""
|
||||||
# Token format must be base64url, 40–50 chars; short or invalid chars → 404
|
|
||||||
r = client.get("/api/calendar/ical/short.ics")
|
r = client.get("/api/calendar/ical/short.ics")
|
||||||
assert r.status_code == 404
|
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")
|
r2 = client.get("/api/calendar/ical/" + "x" * 60 + ".ics")
|
||||||
assert r2.status_code == 404
|
assert r2.status_code == 404
|
||||||
r3 = client.get("/api/calendar/ical/../../../etc/passwd.ics")
|
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")
|
@patch("duty_teller.api.app.get_user_by_calendar_token")
|
||||||
def test_calendar_ical_404_unknown_token(mock_get_user, client):
|
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
|
mock_get_user.return_value = None
|
||||||
# Use a token that passes format validation (base64url, 40–50 chars)
|
|
||||||
valid_format_token = "A" * 43
|
valid_format_token = "A" * 43
|
||||||
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
r = client.get(f"/api/calendar/ical/{valid_format_token}.ics")
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
assert "not found" in r.text.lower()
|
assert r.json() == {"detail": "Not found"}
|
||||||
mock_get_user.assert_called_once()
|
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."""
|
"""require_bot_token() does nothing when BOT_TOKEN is set."""
|
||||||
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
monkeypatch.setattr(config, "BOT_TOKEN", "123:ABC")
|
||||||
config.require_bot_token()
|
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."""
|
"""Tests for duty-schedule JSON parser."""
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from duty_teller.importers.duty_schedule import (
|
from duty_teller.importers.duty_schedule import (
|
||||||
DUTY_MARKERS,
|
DUTY_MARKERS,
|
||||||
|
MAX_FULL_NAME_LENGTH,
|
||||||
|
MAX_SCHEDULE_ROWS,
|
||||||
UNAVAILABLE_MARKER,
|
UNAVAILABLE_MARKER,
|
||||||
VACATION_MARKER,
|
VACATION_MARKER,
|
||||||
DutyScheduleParseError,
|
DutyScheduleParseError,
|
||||||
@@ -118,3 +121,38 @@ def test_unavailable_and_vacation_markers():
|
|||||||
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
assert entry.unavailable_dates == [date(2026, 2, 1)]
|
||||||
assert entry.vacation_dates == [date(2026, 2, 2)]
|
assert entry.vacation_dates == [date(2026, 2, 2)]
|
||||||
assert entry.duty_dates == [date(2026, 2, 3)]
|
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():
|
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
|
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"):
|
with patch.object(mod, "t", return_value="View contacts"):
|
||||||
result = mod._get_contact_button_markup("en")
|
result = mod._get_contact_button_markup("en")
|
||||||
assert result is not None
|
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"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
"""_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
|
@pytest.mark.asyncio
|
||||||
async def test_update_group_pin_repin_raises_still_schedules_next():
|
async def test_update_group_pin_unpin_raises_pin_succeeds_saves_and_schedules():
|
||||||
"""update_group_pin: send_message ok, unpin or pin raises -> no _sync_save_pin, schedule still called, log."""
|
"""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 = MagicMock()
|
||||||
new_msg.message_id = 888
|
new_msg.message_id = 888
|
||||||
context = MagicMock()
|
context = MagicMock()
|
||||||
@@ -287,9 +306,45 @@ async def test_update_group_pin_repin_raises_still_schedules_next():
|
|||||||
context.bot = MagicMock()
|
context.bot = MagicMock()
|
||||||
context.bot.send_message = AsyncMock(return_value=new_msg)
|
context.bot.send_message = AsyncMock(return_value=new_msg)
|
||||||
context.bot.unpin_chat_message = AsyncMock(
|
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.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()
|
context.application = MagicMock()
|
||||||
|
|
||||||
with patch.object(config, "DUTY_PIN_NOTIFY", True):
|
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_save.assert_not_called()
|
||||||
mock_logger.warning.assert_called_once()
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
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 = MagicMock()
|
||||||
update.message = MagicMock()
|
update.message = MagicMock()
|
||||||
update.message.reply_text = AsyncMock()
|
update.message.reply_text = AsyncMock()
|
||||||
@@ -382,16 +437,22 @@ async def test_pin_duty_cmd_group_pins_and_replies_pinned():
|
|||||||
context = MagicMock()
|
context = MagicMock()
|
||||||
context.bot = MagicMock()
|
context.bot = MagicMock()
|
||||||
context.bot.pin_chat_message = AsyncMock()
|
context.bot.pin_chat_message = AsyncMock()
|
||||||
|
context.application = MagicMock()
|
||||||
|
|
||||||
with patch("duty_teller.handlers.group_duty_pin.get_lang", return_value="en"):
|
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_is_trusted", return_value=True):
|
||||||
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
with patch.object(mod, "_sync_get_message_id", return_value=5):
|
||||||
with patch("duty_teller.handlers.group_duty_pin.t") as mock_t:
|
with patch.object(mod, "_get_next_shift_end_sync", return_value=None):
|
||||||
mock_t.return_value = "Pinned"
|
with patch.object(
|
||||||
await mod.pin_duty_cmd(update, context)
|
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(
|
context.bot.pin_chat_message.assert_called_once_with(
|
||||||
chat_id=100, message_id=5, disable_notification=True
|
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")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat():
|
async def test_restore_group_pin_jobs_calls_schedule_for_each_chat_with_jitter():
|
||||||
"""restore_group_pin_jobs: for each chat_id from _get_all_pin_chat_ids_sync, calls _schedule_next_update."""
|
"""restore_group_pin_jobs: for each chat_id calls _schedule_next_update with jitter_seconds=60."""
|
||||||
application = MagicMock()
|
application = MagicMock()
|
||||||
application.job_queue = MagicMock()
|
application.job_queue = MagicMock()
|
||||||
application.job_queue.get_jobs_by_name = MagicMock(return_value=[])
|
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:
|
) as mock_schedule:
|
||||||
await mod.restore_group_pin_jobs(application)
|
await mod.restore_group_pin_jobs(application)
|
||||||
assert mock_schedule.call_count == 2
|
assert mock_schedule.call_count == 2
|
||||||
mock_schedule.assert_any_call(application, 10, None)
|
mock_schedule.assert_any_call(application, 10, None, jitter_seconds=60.0)
|
||||||
mock_schedule.assert_any_call(application, 20, None)
|
mock_schedule.assert_any_call(application, 20, None, jitter_seconds=60.0)
|
||||||
|
|
||||||
|
|
||||||
# --- _refresh_pin_for_chat untrusted ---
|
# --- _refresh_pin_for_chat untrusted ---
|
||||||
|
|||||||
@@ -278,8 +278,8 @@ async def test_handle_duty_schedule_document_non_json_replies_need_json():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_duty_schedule_document_parse_error_replies_and_clears_user_data():
|
async def test_handle_duty_schedule_document_parse_error_replies_generic_and_clears_user_data():
|
||||||
"""handle_duty_schedule_document: parse_duty_schedule raises DutyScheduleParseError -> reply, clear user_data."""
|
"""handle_duty_schedule_document: DutyScheduleParseError -> reply generic message (no str(e)), clear user_data."""
|
||||||
message = MagicMock()
|
message = MagicMock()
|
||||||
message.document = _make_document()
|
message.document = _make_document()
|
||||||
message.reply_text = AsyncMock()
|
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:
|
with patch.object(mod, "parse_duty_schedule") as mock_parse:
|
||||||
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
mock_parse.side_effect = DutyScheduleParseError("Bad JSON")
|
||||||
with patch.object(mod, "t") as mock_t:
|
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)
|
await mod.handle_duty_schedule_document(update, context)
|
||||||
message.reply_text.assert_called_once_with("Parse 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", error="Bad JSON")
|
mock_t.assert_called_with("en", "import.parse_error_generic")
|
||||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||||
assert "handover_utc_time" not in context.user_data
|
assert "handover_utc_time" not in context.user_data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_handle_duty_schedule_document_import_error_replies_and_clears_user_data():
|
async def test_handle_duty_schedule_document_import_error_replies_generic_and_clears_user_data():
|
||||||
"""handle_duty_schedule_document: run_import in executor raises -> reply import_error, clear user_data."""
|
"""handle_duty_schedule_document: run_import raises -> reply generic message (no str(e)), clear user_data."""
|
||||||
message = MagicMock()
|
message = MagicMock()
|
||||||
message.document = _make_document()
|
message.document = _make_document()
|
||||||
message.reply_text = AsyncMock()
|
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:
|
with patch.object(mod, "run_import") as mock_run:
|
||||||
mock_run.side_effect = ValueError("DB error")
|
mock_run.side_effect = ValueError("DB error")
|
||||||
with patch.object(mod, "t") as mock_t:
|
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)
|
await mod.handle_duty_schedule_document(update, context)
|
||||||
message.reply_text.assert_called_once_with("Import error: DB error")
|
message.reply_text.assert_called_once_with("Import failed. Please try again.")
|
||||||
mock_t.assert_called_with("en", "import.import_error", error="DB error")
|
mock_t.assert_called_with("en", "import.import_error_generic")
|
||||||
assert "awaiting_duty_schedule_file" not in context.user_data
|
assert "awaiting_duty_schedule_file" not in context.user_data
|
||||||
assert "handover_utc_time" 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."""
|
"""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
|
from duty_teller.i18n import get_lang, t
|
||||||
|
|
||||||
|
|
||||||
def test_get_lang_none_returns_en():
|
def test_get_lang_always_returns_default_language():
|
||||||
assert get_lang(None) == "en"
|
"""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():
|
def test_get_lang_returns_ru_when_default_language_is_ru():
|
||||||
user = MagicMock()
|
"""When DEFAULT_LANGUAGE is ru, get_lang returns 'ru' regardless of user."""
|
||||||
user.language_code = "ru"
|
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||||
assert get_lang(user) == "ru"
|
mock_cfg.DEFAULT_LANGUAGE = "ru"
|
||||||
|
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||||
|
|
||||||
|
assert core_get_lang(None) == "ru"
|
||||||
|
user = MagicMock()
|
||||||
|
user.language_code = "en"
|
||||||
|
assert core_get_lang(user) == "ru"
|
||||||
|
|
||||||
|
|
||||||
def test_get_lang_ru_ru_returns_ru():
|
def test_get_lang_returns_en_when_default_language_is_en():
|
||||||
user = MagicMock()
|
"""When DEFAULT_LANGUAGE is en, get_lang returns 'en' regardless of user."""
|
||||||
user.language_code = "ru-RU"
|
with patch("duty_teller.i18n.core.config") as mock_cfg:
|
||||||
assert get_lang(user) == "ru"
|
mock_cfg.DEFAULT_LANGUAGE = "en"
|
||||||
|
from duty_teller.i18n.core import get_lang as core_get_lang
|
||||||
|
|
||||||
|
assert core_get_lang(None) == "en"
|
||||||
def test_get_lang_en_returns_en():
|
user = MagicMock()
|
||||||
user = MagicMock()
|
user.language_code = "ru"
|
||||||
user.language_code = "en"
|
assert core_get_lang(user) == "en"
|
||||||
assert get_lang(user) == "en"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_lang_uk_returns_en():
|
|
||||||
user = MagicMock()
|
|
||||||
user.language_code = "uk"
|
|
||||||
assert get_lang(user) == "en"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_lang_empty_returns_en():
|
|
||||||
user = MagicMock()
|
|
||||||
user.language_code = ""
|
|
||||||
assert get_lang(user) == "en"
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_lang_missing_attr_returns_en():
|
|
||||||
user = MagicMock(spec=[]) # no language_code
|
|
||||||
assert get_lang(user) == "en"
|
|
||||||
|
|
||||||
|
|
||||||
def test_t_en_start_greeting():
|
def test_t_en_start_greeting():
|
||||||
|
|||||||
@@ -24,14 +24,23 @@ def test_main_builds_app_and_starts_thread():
|
|||||||
mock_scope.return_value.__exit__.return_value = None
|
mock_scope.return_value.__exit__.return_value = None
|
||||||
|
|
||||||
with patch("duty_teller.run.require_bot_token"):
|
with patch("duty_teller.run.require_bot_token"):
|
||||||
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
with patch("duty_teller.run.config") as mock_cfg:
|
||||||
with patch("duty_teller.run.register_handlers") as mock_register:
|
mock_cfg.MINI_APP_SKIP_AUTH = False
|
||||||
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
mock_cfg.HTTP_HOST = "127.0.0.1"
|
||||||
with patch("duty_teller.db.session.session_scope", mock_scope):
|
mock_cfg.HTTP_PORT = 8080
|
||||||
mock_thread = MagicMock()
|
with patch("duty_teller.run.ApplicationBuilder", return_value=mock_builder):
|
||||||
mock_thread_class.return_value = mock_thread
|
with patch("duty_teller.run.register_handlers") as mock_register:
|
||||||
with pytest.raises(KeyboardInterrupt):
|
with patch("duty_teller.run.threading.Thread") as mock_thread_class:
|
||||||
main()
|
with patch(
|
||||||
|
"duty_teller.run._wait_for_http_ready", return_value=True
|
||||||
|
):
|
||||||
|
with patch(
|
||||||
|
"duty_teller.db.session.session_scope", mock_scope
|
||||||
|
):
|
||||||
|
mock_thread = MagicMock()
|
||||||
|
mock_thread_class.return_value = mock_thread
|
||||||
|
with pytest.raises(KeyboardInterrupt):
|
||||||
|
main()
|
||||||
mock_register.assert_called_once_with(mock_app)
|
mock_register.assert_called_once_with(mock_app)
|
||||||
mock_builder.token.assert_called_once()
|
mock_builder.token.assert_called_once()
|
||||||
mock_thread.start.assert_called_once()
|
mock_thread.start.assert_called_once()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
"""Tests for duty_teller.api.telegram_auth.validate_init_data and validate_init_data_with_reason."""
|
"""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 (
|
from duty_teller.api.telegram_auth import (
|
||||||
validate_init_data,
|
validate_init_data,
|
||||||
validate_init_data_with_reason,
|
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():
|
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"
|
bot_token = "123:ABC"
|
||||||
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
user = {"id": 456, "first_name": "Test", "language_code": "ru"}
|
||||||
init_data = make_init_data(user, bot_token)
|
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 telegram_user_id == 456
|
||||||
assert username is None
|
assert username is None
|
||||||
assert reason == "ok"
|
assert reason == "ok"
|
||||||
assert lang == "ru"
|
assert lang == config.DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
def test_user_without_id_returns_no_user_id():
|
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"
|
bot_token = "123:ABC"
|
||||||
user = {"first_name": "Test"} # no id
|
user = {"first_name": "Test"} # no id
|
||||||
init_data = make_init_data(user, bot_token)
|
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 telegram_user_id is None
|
||||||
assert username is None
|
assert username is None
|
||||||
assert reason == "no_user_id"
|
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():
|
def test_empty_init_data_returns_none():
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Unit tests for utils (dates, user, handover)."""
|
"""Unit tests for utils (dates, user, handover)."""
|
||||||
|
|
||||||
from datetime import date
|
from datetime import date, timedelta
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -62,6 +62,23 @@ def test_validate_date_range_from_after_to():
|
|||||||
assert exc_info.value.kind == "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 ---
|
# --- 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;
|
||||||
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 |
52
webapp-next/src/app/global-error.tsx
Normal file
52
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||||
|
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import "./globals.css";
|
||||||
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
|
|
||||||
|
export default function GlobalError({
|
||||||
|
error,
|
||||||
|
reset,
|
||||||
|
}: {
|
||||||
|
error: Error & { digest?: string };
|
||||||
|
reset: () => void;
|
||||||
|
}) {
|
||||||
|
const lang = getLang();
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang={lang === "ru" ? "ru" : "en"}
|
||||||
|
data-theme="dark"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body className="antialiased">
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{translate(lang, "error_boundary.message")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
{translate(lang, "error_boundary.description")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => reset()}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{translate(lang, "error_boundary.reload")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
webapp-next/src/app/globals.css
Normal file
314
webapp-next/src/app/globals.css
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: ui-monospace, monospace;
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
/* App design tokens (Telegram Mini App) */
|
||||||
|
--color-surface: var(--surface);
|
||||||
|
--color-duty: var(--duty);
|
||||||
|
--color-today: var(--today);
|
||||||
|
--color-unavailable: var(--unavailable);
|
||||||
|
--color-vacation: var(--vacation);
|
||||||
|
--color-error: var(--error);
|
||||||
|
--max-width-app: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App design tokens: use Telegram theme vars with dark fallbacks so TG vars apply before data-theme */
|
||||||
|
:root {
|
||||||
|
--bg: var(--tg-theme-bg-color, #17212b);
|
||||||
|
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||||
|
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||||
|
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||||
|
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||||
|
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||||
|
--card: var(--tg-theme-section-bg-color, var(--surface));
|
||||||
|
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||||
|
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||||
|
--primary: var(--tg-theme-button-color, var(--accent));
|
||||||
|
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||||
|
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||||
|
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||||
|
--duty: #5c9b4a;
|
||||||
|
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||||
|
--unavailable: #b8860b;
|
||||||
|
--vacation: #5a9bb8;
|
||||||
|
--timeline-date-width: 3.6em;
|
||||||
|
--timeline-track-width: 10px;
|
||||||
|
/* Reusable color-mix tokens (avoid repeating in Tailwind classes). */
|
||||||
|
--surface-hover: color-mix(in srgb, var(--accent) 15%, var(--surface));
|
||||||
|
--surface-hover-10: color-mix(in srgb, var(--accent) 10%, var(--surface));
|
||||||
|
--surface-today-tint: color-mix(in srgb, var(--today) 12%, var(--surface));
|
||||||
|
--surface-muted-tint: color-mix(in srgb, var(--muted) 8%, var(--surface));
|
||||||
|
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
|
||||||
|
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
|
||||||
|
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
|
||||||
|
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
|
||||||
|
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
|
||||||
|
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));
|
||||||
|
--indicator-today-duty: color-mix(in srgb, var(--duty) 65%, var(--bg));
|
||||||
|
--indicator-today-unavailable: color-mix(in srgb, var(--unavailable) 65%, var(--bg));
|
||||||
|
--indicator-today-vacation: color-mix(in srgb, var(--vacation) 65%, var(--bg));
|
||||||
|
--indicator-today-events: color-mix(in srgb, var(--accent) 65%, var(--bg));
|
||||||
|
--shadow-card: 0 4px 12px color-mix(in srgb, var(--text) 12%, transparent);
|
||||||
|
--transition-fast: 0.15s;
|
||||||
|
--transition-normal: 0.25s;
|
||||||
|
--ease-out: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--calendar-block-min-height: 260px;
|
||||||
|
/** Minimum height for the 6-row calendar grid so cells stay comfortably large. */
|
||||||
|
--calendar-grid-min-height: 264px;
|
||||||
|
/** Minimum height per calendar row (6 rows × 44px ≈ 264px). */
|
||||||
|
--calendar-row-min-height: 2.75rem;
|
||||||
|
/* Align Tailwind/shadcn semantic tokens with app tokens for Mini App */
|
||||||
|
--background: var(--bg);
|
||||||
|
--foreground: var(--text);
|
||||||
|
--card-foreground: var(--text);
|
||||||
|
--popover: var(--surface);
|
||||||
|
--popover-foreground: var(--text);
|
||||||
|
--secondary: var(--surface);
|
||||||
|
--secondary-foreground: var(--text);
|
||||||
|
--muted-foreground: var(--muted);
|
||||||
|
--accent-foreground: var(--bg);
|
||||||
|
--destructive: var(--error);
|
||||||
|
--input: color-mix(in srgb, var(--text) 15%, transparent);
|
||||||
|
--ring: var(--accent);
|
||||||
|
--chart-1: var(--duty);
|
||||||
|
--chart-2: var(--vacation);
|
||||||
|
--chart-3: var(--unavailable);
|
||||||
|
--chart-4: var(--today);
|
||||||
|
--chart-5: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light theme: full Telegram themeParams (14 params) mapping */
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: var(--tg-theme-bg-color, #f0f1f3);
|
||||||
|
--surface: var(--tg-theme-secondary-bg-color, #e0e2e6);
|
||||||
|
--text: var(--tg-theme-text-color, #343b58);
|
||||||
|
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #6b7089));
|
||||||
|
--accent: var(--tg-theme-link-color, #2e7de0);
|
||||||
|
--header-bg: var(--tg-theme-header-bg-color, #e0e2e6);
|
||||||
|
--card: var(--tg-theme-section-bg-color, #e0e2e6);
|
||||||
|
--section-header: var(--tg-theme-section-header-text-color, #343b58);
|
||||||
|
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 15%, transparent));
|
||||||
|
--primary: var(--tg-theme-button-color, #2e7de0);
|
||||||
|
--primary-foreground: var(--tg-theme-button-text-color, #ffffff);
|
||||||
|
--error: var(--tg-theme-destructive-text-color, #c43b3b);
|
||||||
|
--accent-text: var(--tg-theme-accent-text-color, #2481cc);
|
||||||
|
--duty: #587d0a;
|
||||||
|
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #2481cc));
|
||||||
|
--unavailable: #b8860b;
|
||||||
|
--vacation: #0d6b9e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme: full Telegram themeParams (14 params) mapping */
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: var(--tg-theme-bg-color, #17212b);
|
||||||
|
--surface: var(--tg-theme-secondary-bg-color, #232e3c);
|
||||||
|
--text: var(--tg-theme-text-color, #f5f5f5);
|
||||||
|
--muted: var(--tg-theme-hint-color, var(--tg-theme-subtitle-text-color, #708499));
|
||||||
|
--accent: var(--tg-theme-link-color, #6ab3f3);
|
||||||
|
--header-bg: var(--tg-theme-header-bg-color, #232e3c);
|
||||||
|
--card: var(--tg-theme-section-bg-color, #232e3c);
|
||||||
|
--section-header: var(--tg-theme-section-header-text-color, #f5f5f5);
|
||||||
|
--border: var(--tg-theme-section-separator-color, color-mix(in srgb, var(--text) 10%, transparent));
|
||||||
|
--primary: var(--tg-theme-button-color, #6ab3f3);
|
||||||
|
--primary-foreground: var(--tg-theme-button-text-color, #17212b);
|
||||||
|
--error: var(--tg-theme-destructive-text-color, #e06c75);
|
||||||
|
--accent-text: var(--tg-theme-accent-text-color, #6ab2f2);
|
||||||
|
--duty: #5c9b4a;
|
||||||
|
--today: var(--tg-theme-accent-text-color, var(--tg-theme-link-color, #6ab2f2));
|
||||||
|
--unavailable: #b8860b;
|
||||||
|
--vacation: #5a9bb8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Layout & base (ported from webapp/css/base.css) */
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
html::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container: max-width, padding, safe area. Use .container for main wrapper if needed. */
|
||||||
|
.container-app {
|
||||||
|
max-width: var(--max-width-app, 420px);
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
padding: 12px;
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 12px);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duty list: timeline date cell (non-today) — horizontal line and vertical tick to track */
|
||||||
|
.duty-timeline-date:not(.duty-timeline-date--today)::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 4px;
|
||||||
|
width: calc(100% + var(--timeline-track-width) / 2);
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--muted-fade) 0%,
|
||||||
|
var(--muted-fade) 50%,
|
||||||
|
var(--muted) 70%,
|
||||||
|
var(--muted) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-timeline-date:not(.duty-timeline-date--today)::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||||
|
bottom: 2px;
|
||||||
|
width: 2px;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duty list: timeline date cell (today) — horizontal stripe + vertical tick in today color (same geometry as non-today) */
|
||||||
|
.duty-timeline-date--today::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 5px;
|
||||||
|
width: calc(100% + var(--timeline-track-width) / 2);
|
||||||
|
height: 1px;
|
||||||
|
background: var(--today);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duty-timeline-date--today::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + (var(--timeline-track-width) / 2) - 1px);
|
||||||
|
bottom: 2px;
|
||||||
|
width: 2px;
|
||||||
|
height: 7px;
|
||||||
|
background: var(--today);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duty list: current duty card — ensure left stripe uses --today (matches "Today" label and date stripe) */
|
||||||
|
[data-current-duty] .border-l-today {
|
||||||
|
border-left-color: var(--today);
|
||||||
|
border-left-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Duty list: flip card (front = duty info, back = contacts) */
|
||||||
|
.duty-flip-card {
|
||||||
|
perspective: 600px;
|
||||||
|
}
|
||||||
|
.duty-flip-inner {
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
.duty-flip-front {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
.duty-flip-back {
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||||
|
.pt-safe {
|
||||||
|
padding-top: env(safe-area-inset-top, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||||
|
.sticky.is-scrolled {
|
||||||
|
box-shadow: 0 1px 0 0 var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
|
||||||
|
@keyframes card-appear {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-duty-card {
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
animation: card-appear 0.3s var(--ease-out);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-duty-card--no-duty {
|
||||||
|
border-top-color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
webapp-next/src/app/layout.tsx
Normal file
48
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||||
|
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Duty Teller",
|
||||||
|
description: "Team duty shift calendar and reminders",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
viewportFit: "cover",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<script src="/app/config.js" />
|
||||||
|
</head>
|
||||||
|
<body className="antialiased">
|
||||||
|
<TelegramProvider>
|
||||||
|
<AppErrorBoundary>
|
||||||
|
<TooltipProvider>{children}</TooltipProvider>
|
||||||
|
</AppErrorBoundary>
|
||||||
|
</TelegramProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
webapp-next/src/app/not-found.tsx
Normal file
25
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||||
|
* For static export with a single route this is rarely hit; added for consistency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||||
|
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{t("not_found.open_calendar")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
54
webapp-next/src/app/page.test.tsx
Normal file
54
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Integration test for main page: calendar and header visible, lang from store.
|
||||||
|
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import Page from "./page";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
|
useTelegramAuth: () => ({
|
||||||
|
initDataRaw: "test-init",
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-month-data", () => ({
|
||||||
|
useMonthData: () => ({
|
||||||
|
retry: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("Page", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders calendar and header when store has default state", async () => {
|
||||||
|
render(<Page />);
|
||||||
|
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets document title and lang from store lang", async () => {
|
||||||
|
useAppStore.getState().setLang("en");
|
||||||
|
render(<Page />);
|
||||||
|
await screen.findByRole("grid", { name: "Calendar" });
|
||||||
|
expect(document.title).toBe("Duty Calendar");
|
||||||
|
expect(document.documentElement.lang).toBe("en");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets document title for ru when store lang is ru", async () => {
|
||||||
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||||
|
render(<Page />);
|
||||||
|
await screen.findByRole("grid", { name: "Calendar" });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.title).toBe("Календарь дежурств");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
70
webapp-next/src/app/page.tsx
Normal file
70
webapp-next/src/app/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Main Mini App page: current duty deep link or calendar view.
|
||||||
|
* Delegates to CurrentDutyView or CalendarPage; runs theme and app init.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||||
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||||
|
import { CalendarPage } from "@/components/CalendarPage";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
useTelegramTheme();
|
||||||
|
|
||||||
|
const { initDataRaw, startParam, isLocalhost } = useTelegramAuth();
|
||||||
|
const isAllowed = isLocalhost || !!initDataRaw;
|
||||||
|
|
||||||
|
useAppInit({ isAllowed, startParam });
|
||||||
|
|
||||||
|
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||||
|
useAppStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
currentView: s.currentView,
|
||||||
|
setCurrentView: s.setCurrentView,
|
||||||
|
setSelectedDay: s.setSelectedDay,
|
||||||
|
appContentReady: s.appContentReady,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// When content is ready, tell Telegram to hide native loading and show our app.
|
||||||
|
useEffect(() => {
|
||||||
|
if (appContentReady) {
|
||||||
|
callMiniAppReadyOnce();
|
||||||
|
}
|
||||||
|
}, [appContentReady]);
|
||||||
|
|
||||||
|
const handleBackFromCurrentDuty = useCallback(() => {
|
||||||
|
setCurrentView("calendar");
|
||||||
|
setSelectedDay(null);
|
||||||
|
}, [setCurrentView, setSelectedDay]);
|
||||||
|
|
||||||
|
const content =
|
||||||
|
currentView === "currentDuty" ? (
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||||
|
<CurrentDutyView
|
||||||
|
onBack={handleBackFromCurrentDuty}
|
||||||
|
openedFromPin={startParam === "duty"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
visibility: appContentReady ? "visible" : "hidden",
|
||||||
|
minHeight: "100vh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||||
|
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||||
|
* on React context that might be broken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { getLang } from "@/i18n/messages";
|
||||||
|
import { translate } from "@/i18n/messages";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface AppErrorBoundaryProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||||
|
* instead of crashing. Provides a Reload button to recover.
|
||||||
|
*/
|
||||||
|
export class AppErrorBoundary extends React.Component<
|
||||||
|
AppErrorBoundaryProps,
|
||||||
|
AppErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: AppErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { hasError: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||||
|
return { hasError: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
if (typeof console !== "undefined" && console.error) {
|
||||||
|
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReload = (): void => {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
const lang = getLang();
|
||||||
|
const message = translate(lang, "error_boundary.message");
|
||||||
|
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||||
|
>
|
||||||
|
{reloadLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
179
webapp-next/src/components/CalendarPage.tsx
Normal file
179
webapp-next/src/components/CalendarPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Calendar view layout: header, grid, duty list, day detail.
|
||||||
|
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useMonthData } from "@/hooks/use-month-data";
|
||||||
|
import { useSwipe } from "@/hooks/use-swipe";
|
||||||
|
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||||
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||||
|
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||||
|
import { DutyList } from "@/components/duty/DutyList";
|
||||||
|
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||||
|
import { ErrorState } from "@/components/states/ErrorState";
|
||||||
|
import { AccessDenied } from "@/components/states/AccessDenied";
|
||||||
|
|
||||||
|
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||||
|
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||||
|
|
||||||
|
export interface CalendarPageProps {
|
||||||
|
/** Whether the user is allowed (for data loading). */
|
||||||
|
isAllowed: boolean;
|
||||||
|
/** Raw initData string for API auth. */
|
||||||
|
initDataRaw: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||||
|
const dayDetailRef = useRef<DayDetailHandle>(null);
|
||||||
|
const calendarStickyRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = calendarStickyRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry) setStickyBlockHeight(entry.contentRect.height);
|
||||||
|
});
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
currentMonth,
|
||||||
|
pendingMonth,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
accessDenied,
|
||||||
|
accessDeniedDetail,
|
||||||
|
duties,
|
||||||
|
calendarEvents,
|
||||||
|
selectedDay,
|
||||||
|
nextMonth,
|
||||||
|
prevMonth,
|
||||||
|
setCurrentMonth,
|
||||||
|
setSelectedDay,
|
||||||
|
setAppContentReady,
|
||||||
|
} = useAppStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
currentMonth: s.currentMonth,
|
||||||
|
pendingMonth: s.pendingMonth,
|
||||||
|
loading: s.loading,
|
||||||
|
error: s.error,
|
||||||
|
accessDenied: s.accessDenied,
|
||||||
|
accessDeniedDetail: s.accessDeniedDetail,
|
||||||
|
duties: s.duties,
|
||||||
|
calendarEvents: s.calendarEvents,
|
||||||
|
selectedDay: s.selectedDay,
|
||||||
|
nextMonth: s.nextMonth,
|
||||||
|
prevMonth: s.prevMonth,
|
||||||
|
setCurrentMonth: s.setCurrentMonth,
|
||||||
|
setSelectedDay: s.setSelectedDay,
|
||||||
|
setAppContentReady: s.setAppContentReady,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const { retry } = useMonthData({
|
||||||
|
initDataRaw,
|
||||||
|
enabled: isAllowed,
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const isCurrentMonth =
|
||||||
|
currentMonth.getFullYear() === now.getFullYear() &&
|
||||||
|
currentMonth.getMonth() === now.getMonth();
|
||||||
|
useAutoRefresh(retry, isCurrentMonth);
|
||||||
|
|
||||||
|
const navDisabled = loading || accessDenied || selectedDay !== null;
|
||||||
|
const handlePrevMonth = useCallback(() => {
|
||||||
|
if (navDisabled) return;
|
||||||
|
prevMonth();
|
||||||
|
}, [navDisabled, prevMonth]);
|
||||||
|
const handleNextMonth = useCallback(() => {
|
||||||
|
if (navDisabled) return;
|
||||||
|
nextMonth();
|
||||||
|
}, [navDisabled, nextMonth]);
|
||||||
|
|
||||||
|
useSwipe(
|
||||||
|
calendarStickyRef,
|
||||||
|
handleNextMonth,
|
||||||
|
handlePrevMonth,
|
||||||
|
{ threshold: 50, disabled: navDisabled }
|
||||||
|
);
|
||||||
|
useStickyScroll(calendarStickyRef);
|
||||||
|
|
||||||
|
const handleDayClick = useCallback(
|
||||||
|
(dateKey: string, anchorRect: DOMRect) => {
|
||||||
|
const [y, m] = dateKey.split("-").map(Number);
|
||||||
|
if (
|
||||||
|
y !== currentMonth.getFullYear() ||
|
||||||
|
m !== currentMonth.getMonth() + 1
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
|
||||||
|
},
|
||||||
|
[currentMonth]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseDayDetail = useCallback(() => {
|
||||||
|
setSelectedDay(null);
|
||||||
|
}, [setSelectedDay]);
|
||||||
|
|
||||||
|
const readyCalledRef = useRef(false);
|
||||||
|
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
|
||||||
|
useEffect(() => {
|
||||||
|
if ((!loading || accessDenied) && !readyCalledRef.current) {
|
||||||
|
readyCalledRef.current = true;
|
||||||
|
setAppContentReady(true);
|
||||||
|
}
|
||||||
|
}, [loading, accessDenied, setAppContentReady]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||||
|
<div
|
||||||
|
ref={calendarStickyRef}
|
||||||
|
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||||
|
>
|
||||||
|
<CalendarHeader
|
||||||
|
month={currentMonth}
|
||||||
|
disabled={navDisabled}
|
||||||
|
onPrevMonth={handlePrevMonth}
|
||||||
|
onNextMonth={handleNextMonth}
|
||||||
|
/>
|
||||||
|
<CalendarGrid
|
||||||
|
currentMonth={currentMonth}
|
||||||
|
duties={duties}
|
||||||
|
calendarEvents={calendarEvents}
|
||||||
|
onDayClick={handleDayClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accessDenied && (
|
||||||
|
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
||||||
|
)}
|
||||||
|
{!accessDenied && error && (
|
||||||
|
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||||
|
)}
|
||||||
|
{!accessDenied && !error && (
|
||||||
|
<DutyList
|
||||||
|
scrollMarginTop={stickyBlockHeight}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DayDetail
|
||||||
|
ref={dayDetailRef}
|
||||||
|
duties={duties}
|
||||||
|
calendarEvents={calendarEvents}
|
||||||
|
onClose={handleCloseDayDetail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||||
|
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { CalendarDay } from "./CalendarDay";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("CalendarDay", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
dateKey: "2025-02-15",
|
||||||
|
dayOfMonth: 15,
|
||||||
|
isToday: false,
|
||||||
|
duties: [],
|
||||||
|
eventSummaries: [],
|
||||||
|
onDayClick: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||||
|
const onDayClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<CalendarDay
|
||||||
|
{...defaultProps}
|
||||||
|
isOtherMonth={false}
|
||||||
|
onDayClick={onDayClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onDayClick).toHaveBeenCalledWith(
|
||||||
|
"2025-02-15",
|
||||||
|
expect.objectContaining({
|
||||||
|
width: expect.any(Number),
|
||||||
|
height: expect.any(Number),
|
||||||
|
top: expect.any(Number),
|
||||||
|
left: expect.any(Number),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||||
|
const onDayClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<CalendarDay
|
||||||
|
{...defaultProps}
|
||||||
|
isOtherMonth={true}
|
||||||
|
onDayClick={onDayClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(onDayClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||||
|
render(
|
||||||
|
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||||
|
render(
|
||||||
|
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /15/ });
|
||||||
|
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
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 showIndicator = !isOtherMonth;
|
||||||
|
const hasAny = duties.length > 0 || hasEvent;
|
||||||
|
|
||||||
|
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||||
|
if (hasAny && showIndicator) {
|
||||||
|
const counts: string[] = [];
|
||||||
|
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||||
|
if (unavailableList.length)
|
||||||
|
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||||
|
if (vacationList.length)
|
||||||
|
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||||
|
if (hasEvent) counts.push(t("hint.events"));
|
||||||
|
ariaParts.push(counts.join(", "));
|
||||||
|
} else {
|
||||||
|
ariaParts.push(t("aria.day_info"));
|
||||||
|
}
|
||||||
|
const ariaLabel = ariaParts.join("; ");
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-disabled={isOtherMonth}
|
||||||
|
data-date={dateKey}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||||
|
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||||
|
isOtherMonth &&
|
||||||
|
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||||
|
!isOtherMonth && [
|
||||||
|
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||||
|
"active:scale-[0.98] cursor-pointer",
|
||||||
|
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||||
|
],
|
||||||
|
showIndicator && hasAny && "font-bold",
|
||||||
|
showIndicator &&
|
||||||
|
hasEvent &&
|
||||||
|
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||||
|
isToday &&
|
||||||
|
hasEvent &&
|
||||||
|
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (isOtherMonth) return;
|
||||||
|
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="num">{dayOfMonth}</span>
|
||||||
|
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||||
|
<DayIndicators
|
||||||
|
dutyCount={dutyList.length}
|
||||||
|
unavailableCount={unavailableList.length}
|
||||||
|
vacationCount={vacationList.length}
|
||||||
|
hasEvents={hasEvent}
|
||||||
|
isToday={isToday}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||||
|
return (
|
||||||
|
prev.dateKey === next.dateKey &&
|
||||||
|
prev.dayOfMonth === next.dayOfMonth &&
|
||||||
|
prev.isToday === next.isToday &&
|
||||||
|
prev.isOtherMonth === next.isOtherMonth &&
|
||||||
|
prev.duties === next.duties &&
|
||||||
|
prev.eventSummaries === next.eventSummaries &&
|
||||||
|
prev.onDayClick === next.onDayClick
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||||
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
|
||||||
|
* Ported from webapp/js/calendar.js renderCalendar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
firstDayOfMonth,
|
||||||
|
getMonday,
|
||||||
|
localDateString,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||||
|
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CalendarDay } from "./CalendarDay";
|
||||||
|
|
||||||
|
export interface CalendarGridProps {
|
||||||
|
/** Currently displayed month. */
|
||||||
|
currentMonth: Date;
|
||||||
|
/** All duties for the visible range (will be grouped by date). */
|
||||||
|
duties: DutyWithUser[];
|
||||||
|
/** All calendar events for the visible range. */
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
|
||||||
|
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CELLS = 42;
|
||||||
|
|
||||||
|
export function CalendarGrid({
|
||||||
|
currentMonth,
|
||||||
|
duties,
|
||||||
|
calendarEvents,
|
||||||
|
onDayClick,
|
||||||
|
className,
|
||||||
|
}: CalendarGridProps) {
|
||||||
|
const dutiesByDateMap = useMemo(
|
||||||
|
() => dutiesByDate(duties),
|
||||||
|
[duties]
|
||||||
|
);
|
||||||
|
const calendarEventsByDateMap = useMemo(
|
||||||
|
() => calendarEventsByDate(calendarEvents),
|
||||||
|
[calendarEvents]
|
||||||
|
);
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
|
||||||
|
const cells = useMemo(() => {
|
||||||
|
const first = firstDayOfMonth(currentMonth);
|
||||||
|
const start = getMonday(first);
|
||||||
|
const result: { date: Date; key: string; month: number }[] = [];
|
||||||
|
const d = new Date(start);
|
||||||
|
for (let i = 0; i < CELLS; i++) {
|
||||||
|
const key = localDateString(d);
|
||||||
|
result.push({ date: new Date(d), key, month: d.getMonth() });
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [currentMonth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="grid"
|
||||||
|
aria-label="Calendar"
|
||||||
|
>
|
||||||
|
{cells.map(({ date, key, month }, i) => {
|
||||||
|
const isOtherMonth = month !== currentMonth.getMonth();
|
||||||
|
const dayDuties = dutiesByDateMap[key] ?? [];
|
||||||
|
const eventSummaries = calendarEventsByDateMap[key] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`cell-${i}`} role="gridcell" className="min-h-0">
|
||||||
|
<CalendarDay
|
||||||
|
dateKey={key}
|
||||||
|
dayOfMonth={date.getDate()}
|
||||||
|
isToday={key === todayKey}
|
||||||
|
isOtherMonth={isOtherMonth}
|
||||||
|
duties={dayDuties}
|
||||||
|
eventSummaries={eventSummaries}
|
||||||
|
onDayClick={onDayClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
82
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Calendar header: month title, prev/next navigation, weekday labels.
|
||||||
|
* Replaces the header from webapp index.html and calendar.js month title.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
ChevronLeft as ChevronLeftIcon,
|
||||||
|
ChevronRight as ChevronRightIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
export interface CalendarHeaderProps {
|
||||||
|
/** Currently displayed month (used for title). */
|
||||||
|
month: Date;
|
||||||
|
/** Whether month navigation is disabled (e.g. during loading). */
|
||||||
|
disabled?: boolean;
|
||||||
|
onPrevMonth: () => void;
|
||||||
|
onNextMonth: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CalendarHeader({
|
||||||
|
month,
|
||||||
|
disabled = false,
|
||||||
|
onPrevMonth,
|
||||||
|
onNextMonth,
|
||||||
|
className,
|
||||||
|
}: CalendarHeaderProps) {
|
||||||
|
const { t, monthName, weekdayLabels } = useTranslation();
|
||||||
|
const year = month.getFullYear();
|
||||||
|
const monthIndex = month.getMonth();
|
||||||
|
const labels = weekdayLabels();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cn("flex flex-col", className)}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||||
|
aria-label={t("nav.prev_month")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onPrevMonth}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
<div className="flex flex-col items-center gap-0.5">
|
||||||
|
<h1
|
||||||
|
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{monthName(monthIndex)} {year}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="icon"
|
||||||
|
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||||
|
aria-label={t("nav.next_month")}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onNextMonth}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
|
||||||
|
{labels.map((label, i) => (
|
||||||
|
<span key={i} aria-hidden>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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";
|
||||||
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
|
||||||
|
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ContactLinks } from "./ContactLinks";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("ContactLinks", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when phone and username are missing", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ContactLinks phone={null} username={null} />
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders phone only with label and tel: link", () => {
|
||||||
|
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
|
||||||
|
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays phone formatted for Russian numbers", () => {
|
||||||
|
render(<ContactLinks phone="79146522209" username={null} />);
|
||||||
|
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders username only with label and t.me link", () => {
|
||||||
|
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
|
||||||
|
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both phone and username with labels", () => {
|
||||||
|
render(
|
||||||
|
<ContactLinks
|
||||||
|
phone="+79001112233"
|
||||||
|
username="bob"
|
||||||
|
showLabels
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/@bob/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips leading @ from username and displays with @", () => {
|
||||||
|
render(<ContactLinks phone={null} username="@alice" />);
|
||||||
|
const link = document.querySelector('a[href*="t.me/alice"]');
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link?.textContent).toContain("@alice");
|
||||||
|
});
|
||||||
|
});
|
||||||
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* Contact links (phone, Telegram) for duty cards and day detail.
|
||||||
|
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
||||||
|
|
||||||
|
export interface ContactLinksProps {
|
||||||
|
phone?: string | null;
|
||||||
|
username?: string | null;
|
||||||
|
layout?: "inline" | "block";
|
||||||
|
showLabels?: boolean;
|
||||||
|
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||||
|
contextLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
||||||
|
*/
|
||||||
|
export function ContactLinks({
|
||||||
|
phone,
|
||||||
|
username,
|
||||||
|
layout = "inline",
|
||||||
|
showLabels = true,
|
||||||
|
contextLabel,
|
||||||
|
className,
|
||||||
|
}: ContactLinksProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasPhone = Boolean(phone && String(phone).trim());
|
||||||
|
const rawUsername = username && String(username).trim();
|
||||||
|
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||||
|
const hasUsername = Boolean(cleanUsername);
|
||||||
|
|
||||||
|
if (!hasPhone && !hasUsername) return null;
|
||||||
|
|
||||||
|
const ariaCall = contextLabel
|
||||||
|
? t("contact.aria_call", { name: contextLabel })
|
||||||
|
: t("contact.phone");
|
||||||
|
const ariaTelegram = contextLabel
|
||||||
|
? t("contact.aria_telegram", { name: contextLabel })
|
||||||
|
: t("contact.telegram");
|
||||||
|
|
||||||
|
if (layout === "block") {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-2", className)}>
|
||||||
|
{hasPhone && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
|
||||||
|
<PhoneIcon className="size-5" aria-hidden />
|
||||||
|
<span>{formatPhoneDisplay(phone!)}</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasUsername && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={ariaTelegram}
|
||||||
|
>
|
||||||
|
<TelegramIcon className="size-5" aria-hidden />
|
||||||
|
<span>@{cleanUsername}</span>
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts: React.ReactNode[] = [];
|
||||||
|
if (hasPhone) {
|
||||||
|
const displayPhone = formatPhoneDisplay(phone!);
|
||||||
|
parts.push(
|
||||||
|
showLabels ? (
|
||||||
|
<span key="phone">
|
||||||
|
{t("contact.phone")}:{" "}
|
||||||
|
<a
|
||||||
|
href={`tel:${String(phone).trim()}`}
|
||||||
|
className={linkClass}
|
||||||
|
aria-label={ariaCall}
|
||||||
|
>
|
||||||
|
{displayPhone}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
key="phone"
|
||||||
|
href={`tel:${String(phone).trim()}`}
|
||||||
|
className={linkClass}
|
||||||
|
aria-label={ariaCall}
|
||||||
|
>
|
||||||
|
{displayPhone}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hasUsername) {
|
||||||
|
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||||
|
const link = (
|
||||||
|
<a
|
||||||
|
key="tg"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
aria-label={ariaTelegram}
|
||||||
|
>
|
||||||
|
@{cleanUsername}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
||||||
|
{parts.map((p, i) => (
|
||||||
|
<span key={i} className="inline-flex items-center gap-x-1">
|
||||||
|
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
||||||
|
{p}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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";
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
|
||||||
|
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { CurrentDutyView } from "./CurrentDutyView";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
|
useTelegramAuth: () => ({
|
||||||
|
initDataRaw: "test-init",
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", () => ({
|
||||||
|
fetchDuties: vi.fn().mockResolvedValue([]),
|
||||||
|
AccessDeniedError: class AccessDeniedError extends Error {
|
||||||
|
serverDetail?: string;
|
||||||
|
constructor(m: string, d?: string) {
|
||||||
|
super(m);
|
||||||
|
this.serverDetail = d;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CurrentDutyView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading then no-duty message when no active duty", async () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||||
|
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("back button calls onBack when clicked", async () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||||
|
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
|
||||||
|
fireEvent.click(buttons[buttons.length - 1]);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Close button when openedFromPin is true", async () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
|
||||||
|
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||||
|
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows contact info not set when duty has no phone or username", async () => {
|
||||||
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||||
|
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||||
|
const dutyNoContacts = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
event_type: "duty" as const,
|
||||||
|
full_name: "Test User",
|
||||||
|
phone: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
||||||
|
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
|
||||||
|
* and contact links. Integrates with Telegram BackButton.
|
||||||
|
* Ported from webapp/js/currentDuty.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
|
||||||
|
import { Calendar } from "lucide-react";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
localDateString,
|
||||||
|
dateKeyToDDMM,
|
||||||
|
formatHHMM,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||||
|
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import type { DutyWithUser } from "@/types";
|
||||||
|
|
||||||
|
export interface CurrentDutyViewProps {
|
||||||
|
/** Called when user taps Back (in-app button or Telegram BackButton). */
|
||||||
|
onBack: () => void;
|
||||||
|
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
|
||||||
|
openedFromPin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ViewState = "loading" | "error" | "ready";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||||
|
*/
|
||||||
|
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const lang = useAppStore((s) => s.lang);
|
||||||
|
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||||
|
const { initDataRaw } = useTelegramAuth();
|
||||||
|
|
||||||
|
const [state, setState] = useState<ViewState>("loading");
|
||||||
|
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||||
|
|
||||||
|
const loadTodayDuties = useCallback(
|
||||||
|
async (signal?: AbortSignal | null) => {
|
||||||
|
const today = new Date();
|
||||||
|
const from = localDateString(today);
|
||||||
|
const to = from;
|
||||||
|
const initData = initDataRaw ?? "";
|
||||||
|
try {
|
||||||
|
const duties = await fetchDuties(from, to, initData, lang, signal);
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
const active = findCurrentDuty(duties);
|
||||||
|
setDuty(active);
|
||||||
|
setState("ready");
|
||||||
|
if (active) {
|
||||||
|
setRemaining(getRemainingTime(active.end_at));
|
||||||
|
} else {
|
||||||
|
setRemaining(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
setState("error");
|
||||||
|
const msg =
|
||||||
|
e instanceof AccessDeniedError && e.serverDetail
|
||||||
|
? e.serverDetail
|
||||||
|
: t("error_generic");
|
||||||
|
setErrorMessage(msg);
|
||||||
|
setDuty(null);
|
||||||
|
setRemaining(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[initDataRaw, lang, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
loadTodayDuties(controller.signal);
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [loadTodayDuties]);
|
||||||
|
|
||||||
|
// Mark content ready when data is loaded or error, so page can call ready() and show content.
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== "loading") {
|
||||||
|
setAppContentReady(true);
|
||||||
|
}
|
||||||
|
}, [state, setAppContentReady]);
|
||||||
|
|
||||||
|
// Auto-update remaining time every second when there is an active duty.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!duty) return;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setRemaining(getRemainingTime(duty.end_at));
|
||||||
|
}, 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [duty]);
|
||||||
|
|
||||||
|
// Telegram BackButton: show on mount, hide on unmount, handle click.
|
||||||
|
useEffect(() => {
|
||||||
|
let offClick: (() => void) | undefined;
|
||||||
|
try {
|
||||||
|
if (backButton.mount.isAvailable()) {
|
||||||
|
backButton.mount();
|
||||||
|
}
|
||||||
|
if (backButton.show.isAvailable()) {
|
||||||
|
backButton.show();
|
||||||
|
}
|
||||||
|
if (backButton.onClick.isAvailable()) {
|
||||||
|
offClick = backButton.onClick(onBack);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-Telegram environment; BackButton not available.
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
try {
|
||||||
|
if (typeof offClick === "function") offClick();
|
||||||
|
if (backButton.hide.isAvailable()) {
|
||||||
|
backButton.hide();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors in non-Telegram environment.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [onBack]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
onBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (closeMiniApp.isAvailable()) {
|
||||||
|
closeMiniApp();
|
||||||
|
} else {
|
||||||
|
onBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
|
||||||
|
const primaryButtonAriaLabel = openedFromPin
|
||||||
|
? t("current_duty.close")
|
||||||
|
: t("current_duty.back");
|
||||||
|
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
|
||||||
|
|
||||||
|
if (state === "loading") {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("loading")}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground m-0">{t("loading")}</p>
|
||||||
|
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||||
|
{primaryButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === "error") {
|
||||||
|
const handleRetry = () => {
|
||||||
|
setState("loading");
|
||||||
|
loadTodayDuties();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||||
|
<Card className="w-full max-w-[var(--max-width-app)]">
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
<p className="text-error">{errorMessage}</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={handleRetry}
|
||||||
|
aria-label={t("error.retry")}
|
||||||
|
>
|
||||||
|
{t("error.retry")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
aria-label={primaryButtonAriaLabel}
|
||||||
|
>
|
||||||
|
{primaryButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!duty) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||||
|
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("current_duty.title")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col items-center gap-4">
|
||||||
|
<span
|
||||||
|
className="flex items-center justify-center text-muted-foreground"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<Calendar className="size-12" strokeWidth={1.5} />
|
||||||
|
</span>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
{t("current_duty.no_duty")}
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||||
|
{primaryButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLocal = localDateString(new Date(duty.start_at));
|
||||||
|
const endLocal = localDateString(new Date(duty.end_at));
|
||||||
|
const startDDMM = dateKeyToDDMM(startLocal);
|
||||||
|
const endDDMM = dateKeyToDDMM(endLocal);
|
||||||
|
const startTime = formatHHMM(duty.start_at);
|
||||||
|
const endTime = formatHHMM(duty.end_at);
|
||||||
|
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
||||||
|
const rem = remaining ?? getRemainingTime(duty.end_at);
|
||||||
|
const remainingStr = t("current_duty.remaining", {
|
||||||
|
hours: String(rem.hours),
|
||||||
|
minutes: String(rem.minutes),
|
||||||
|
});
|
||||||
|
const endsAtStr = t("current_duty.ends_at", { time: endTime });
|
||||||
|
|
||||||
|
const displayTz =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
|
||||||
|
const shiftLabel = displayTz
|
||||||
|
? t("current_duty.shift_tz", { tz: displayTz })
|
||||||
|
: t("current_duty.shift_local");
|
||||||
|
|
||||||
|
const hasContacts =
|
||||||
|
Boolean(duty.phone && String(duty.phone).trim()) ||
|
||||||
|
Boolean(duty.username && String(duty.username).trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
|
||||||
|
<Card
|
||||||
|
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
|
||||||
|
role="article"
|
||||||
|
aria-labelledby="current-duty-title"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle
|
||||||
|
id="current-duty-title"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{t("current_duty.title")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="font-medium text-foreground" id="current-duty-name">
|
||||||
|
{duty.full_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{shiftLabel} {shiftStr}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
{remainingStr}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
|
||||||
|
{hasContacts ? (
|
||||||
|
<ContactLinks
|
||||||
|
phone={duty.phone}
|
||||||
|
username={duty.username}
|
||||||
|
layout="block"
|
||||||
|
showLabels={true}
|
||||||
|
contextLabel={duty.full_name ?? undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("current_duty.contact_info_not_set")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
aria-label={primaryButtonAriaLabel}
|
||||||
|
>
|
||||||
|
{primaryButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
/**
|
||||||
|
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
|
||||||
|
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
|
||||||
|
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||||
|
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||||
|
import { DayDetailContent } from "./DayDetailContent";
|
||||||
|
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
/** Empty state for day detail: date and "no duties or events" message. */
|
||||||
|
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
const ddmm = dateKeyToDDMM(dateKey);
|
||||||
|
const title =
|
||||||
|
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2
|
||||||
|
id="day-detail-title"
|
||||||
|
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground m-0">
|
||||||
|
{t("day_detail.no_events")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayDetailHandle {
|
||||||
|
/** Open the panel for the given day with popover anchored at the given rect. */
|
||||||
|
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayDetailProps {
|
||||||
|
/** All duties for the visible range (will be filtered by selectedDay). */
|
||||||
|
duties: DutyWithUser[];
|
||||||
|
/** All calendar events for the visible range. */
|
||||||
|
calendarEvents: CalendarEvent[];
|
||||||
|
/** Called when the panel should close. */
|
||||||
|
onClose: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
|
||||||
|
*/
|
||||||
|
function VirtualAnchor({
|
||||||
|
rect,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
rect: DOMRect;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("pointer-events-none fixed z-0", className)}
|
||||||
|
style={{
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.bottom,
|
||||||
|
width: rect.width,
|
||||||
|
height: 1,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||||
|
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
|
||||||
|
const isDesktop = useIsDesktop();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const selectedDay = useAppStore((s) => s.selectedDay);
|
||||||
|
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
|
||||||
|
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
||||||
|
const [exiting, setExiting] = React.useState(false);
|
||||||
|
|
||||||
|
const open = selectedDay !== null;
|
||||||
|
|
||||||
|
React.useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
openWithRect(dateKey: string, rect: DOMRect) {
|
||||||
|
setSelectedDay(dateKey);
|
||||||
|
setAnchorRect(rect);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[setSelectedDay]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = React.useCallback(() => {
|
||||||
|
setSelectedDay(null);
|
||||||
|
setAnchorRect(null);
|
||||||
|
setExiting(false);
|
||||||
|
onClose();
|
||||||
|
}, [setSelectedDay, onClose]);
|
||||||
|
|
||||||
|
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
|
||||||
|
const requestClose = React.useCallback(() => {
|
||||||
|
setExiting(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!exiting) return;
|
||||||
|
const fallback = window.setTimeout(() => {
|
||||||
|
handleClose();
|
||||||
|
}, 320);
|
||||||
|
return () => window.clearTimeout(fallback);
|
||||||
|
}, [exiting, handleClose]);
|
||||||
|
|
||||||
|
const dutiesByDateMap = React.useMemo(
|
||||||
|
() => dutiesByDate(duties),
|
||||||
|
[duties]
|
||||||
|
);
|
||||||
|
const eventsByDateMap = React.useMemo(
|
||||||
|
() => calendarEventsByDate(calendarEvents),
|
||||||
|
[calendarEvents]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
|
||||||
|
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
|
||||||
|
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
|
||||||
|
|
||||||
|
// Close popover/sheet on window resize so anchor position does not become stale.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onResize = () => handleClose();
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, [open, handleClose]);
|
||||||
|
|
||||||
|
const content =
|
||||||
|
selectedDay &&
|
||||||
|
(hasContent ? (
|
||||||
|
<DayDetailContent
|
||||||
|
dateKey={selectedDay}
|
||||||
|
duties={dayDuties}
|
||||||
|
eventSummaries={dayEvents}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DayDetailEmpty dateKey={selectedDay} />
|
||||||
|
));
|
||||||
|
|
||||||
|
if (!open || !selectedDay) return null;
|
||||||
|
|
||||||
|
const panelClassName =
|
||||||
|
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||||
|
const closeButton = (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||||
|
onClick={requestClose}
|
||||||
|
aria-label={t("day_detail.close")}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSheet = (withHandle: boolean) => (
|
||||||
|
<Sheet
|
||||||
|
open={!exiting && open}
|
||||||
|
onOpenChange={(o) => !o && requestClose()}
|
||||||
|
>
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
className={cn(
|
||||||
|
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
showCloseButton={false}
|
||||||
|
onCloseAnimationEnd={handleClose}
|
||||||
|
>
|
||||||
|
<div className="relative px-4">
|
||||||
|
{closeButton}
|
||||||
|
{withHandle && (
|
||||||
|
<div
|
||||||
|
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SheetHeader className="p-0">
|
||||||
|
<SheetTitle id="day-detail-sheet-title" className="sr-only">
|
||||||
|
{selectedDay}
|
||||||
|
</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDesktop === true && anchorRect != null) {
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
|
<PopoverAnchor asChild>
|
||||||
|
<VirtualAnchor rect={anchorRect} />
|
||||||
|
</PopoverAnchor>
|
||||||
|
<PopoverContent
|
||||||
|
side="bottom"
|
||||||
|
sideOffset={8}
|
||||||
|
align="center"
|
||||||
|
className={cn(panelClassName, "relative", className)}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
|
onEscapeKeyDown={handleClose}
|
||||||
|
>
|
||||||
|
{closeButton}
|
||||||
|
{content}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderSheet(true);
|
||||||
|
}
|
||||||
|
);
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
78
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for DutyList: renders timeline, flip card with contacts, duty items.
|
||||||
|
* Ported from webapp/js/dutyList.test.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { DutyList } from "./DutyList";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
import type { DutyWithUser } from "@/types";
|
||||||
|
|
||||||
|
function duty(
|
||||||
|
full_name: string,
|
||||||
|
start_at: string,
|
||||||
|
end_at: string,
|
||||||
|
extra: Partial<DutyWithUser> = {}
|
||||||
|
): DutyWithUser {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
full_name,
|
||||||
|
start_at,
|
||||||
|
end_at,
|
||||||
|
event_type: "duty",
|
||||||
|
phone: null,
|
||||||
|
username: null,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DutyList", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no duties message when duties empty and data loaded for month", () => {
|
||||||
|
useAppStore.getState().setDuties([]);
|
||||||
|
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
|
||||||
|
render(<DutyList />);
|
||||||
|
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders compact loading placeholder when data not yet loaded for month (no skeleton)", () => {
|
||||||
|
useAppStore.getState().setDuties([]);
|
||||||
|
useAppStore.getState().batchUpdate({ dataForMonthKey: null });
|
||||||
|
render(<DutyList />);
|
||||||
|
expect(screen.queryByText(/No duties this month/i)).not.toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('[data-slot="skeleton"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders duty with full_name and time range", () => {
|
||||||
|
useAppStore.getState().setDuties([
|
||||||
|
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
|
||||||
|
]);
|
||||||
|
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
|
||||||
|
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
|
||||||
|
render(<DutyList />);
|
||||||
|
expect(screen.getByText("Иванов")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders flip card with contact links when phone or username present", () => {
|
||||||
|
useAppStore.getState().setDuties([
|
||||||
|
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
|
||||||
|
phone: "+79991234567",
|
||||||
|
username: "alice_dev",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
|
||||||
|
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-03" });
|
||||||
|
render(<DutyList />);
|
||||||
|
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||||
|
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
278
webapp-next/src/components/duty/DutyList.tsx
Normal file
278
webapp-next/src/components/duty/DutyList.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Duty timeline list for the current month: dates, track line, flip cards.
|
||||||
|
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import {
|
||||||
|
localDateString,
|
||||||
|
firstDayOfMonth,
|
||||||
|
lastDayOfMonth,
|
||||||
|
dateKeyToDDMM,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { LoadingState } from "@/components/states/LoadingState";
|
||||||
|
import { DutyTimelineCard } from "./DutyTimelineCard";
|
||||||
|
|
||||||
|
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
|
||||||
|
const SCROLL_OVERLAP_PX = 14;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton placeholder for duty list (e.g. when loading a new month).
|
||||||
|
* Shows 4 card-shaped placeholders in timeline layout.
|
||||||
|
*/
|
||||||
|
export function DutyListSkeleton({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
|
||||||
|
<div
|
||||||
|
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Skeleton className="h-14 w-12 rounded" />
|
||||||
|
<span className="min-w-0" aria-hidden />
|
||||||
|
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DutyListProps {
|
||||||
|
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
|
||||||
|
scrollMarginTop?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
|
||||||
|
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
|
||||||
|
*/
|
||||||
|
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { currentMonth, duties, dataForMonthKey } = useAppStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
currentMonth: s.currentMonth,
|
||||||
|
duties: s.duties,
|
||||||
|
dataForMonthKey: s.dataForMonthKey,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const hasDataForMonth = dataForMonthKey === monthKey;
|
||||||
|
|
||||||
|
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
|
||||||
|
const filteredList = duties.filter((d) => d.event_type === "duty");
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
const firstKey = localDateString(firstDayOfMonth(currentMonth));
|
||||||
|
const lastKey = localDateString(lastDayOfMonth(currentMonth));
|
||||||
|
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
||||||
|
|
||||||
|
const dateSet = new Set<string>();
|
||||||
|
filteredList.forEach((d) =>
|
||||||
|
dateSet.add(localDateString(new Date(d.start_at)))
|
||||||
|
);
|
||||||
|
if (showTodayInMonth) dateSet.add(todayKey);
|
||||||
|
const datesList = Array.from(dateSet).sort();
|
||||||
|
|
||||||
|
const byDate: Record<string, typeof filteredList> = {};
|
||||||
|
datesList.forEach((date) => {
|
||||||
|
byDate[date] = filteredList
|
||||||
|
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
filtered: filteredList,
|
||||||
|
dates: datesList,
|
||||||
|
dutiesByDateKey: byDate,
|
||||||
|
};
|
||||||
|
}, [currentMonth, duties]);
|
||||||
|
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
const [flippedDutyId, setFlippedDutyId] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
const scrolledForMonthRef = useRef<string | null>(null);
|
||||||
|
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
|
||||||
|
const prevMonthKeyRef = useRef<string>(monthKey);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
|
||||||
|
scrolledForMonthRef.current = null;
|
||||||
|
prevScrollMarginTopRef.current = scrollMarginTop;
|
||||||
|
}
|
||||||
|
if (prevMonthKeyRef.current !== monthKey) {
|
||||||
|
scrolledForMonthRef.current = null;
|
||||||
|
prevMonthKeyRef.current = monthKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = listRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
|
||||||
|
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
|
||||||
|
const target = currentCard ?? todayBlock;
|
||||||
|
if (!target || scrolledForMonthRef.current === monthKey) return;
|
||||||
|
|
||||||
|
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
|
||||||
|
const scrollTo = () => {
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const scrollTop = window.scrollY + rect.top - effectiveMargin;
|
||||||
|
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
||||||
|
scrolledForMonthRef.current = monthKey;
|
||||||
|
};
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(scrollTo);
|
||||||
|
});
|
||||||
|
}, [filtered, dates.length, scrollMarginTop, monthKey]);
|
||||||
|
|
||||||
|
if (!hasDataForMonth) {
|
||||||
|
return (
|
||||||
|
<div aria-busy="true" className={className}>
|
||||||
|
<LoadingState asPlaceholder />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1", className)}>
|
||||||
|
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
|
||||||
|
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={listRef} className={className}>
|
||||||
|
<div className="duty-timeline relative text-[0.9rem]">
|
||||||
|
{/* Vertical track line */}
|
||||||
|
<div
|
||||||
|
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{dates.map((date) => {
|
||||||
|
const isToday = date === todayKey;
|
||||||
|
const dateLabel = dateKeyToDDMM(date);
|
||||||
|
const dayDuties = dutiesByDateKey[date] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
className="mb-0"
|
||||||
|
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
|
||||||
|
data-date={date}
|
||||||
|
data-today-block={isToday ? true : undefined}
|
||||||
|
>
|
||||||
|
{dayDuties.length > 0 ? (
|
||||||
|
dayDuties.map((duty) => {
|
||||||
|
const start = new Date(duty.start_at);
|
||||||
|
const end = new Date(duty.end_at);
|
||||||
|
const isCurrent = start <= now && now < end;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={duty.id}
|
||||||
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineDateCell
|
||||||
|
dateLabel={dateLabel}
|
||||||
|
isToday={isToday}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="min-w-0"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="min-w-0 overflow-hidden"
|
||||||
|
{...(isCurrent ? { "data-current-duty": true } : {})}
|
||||||
|
>
|
||||||
|
<DutyTimelineCard
|
||||||
|
duty={duty}
|
||||||
|
isCurrent={isCurrent}
|
||||||
|
isFlipped={flippedDutyId === duty.id}
|
||||||
|
onFlipChange={(flip) =>
|
||||||
|
setFlippedDutyId(flip ? duty.id : null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns:
|
||||||
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineDateCell
|
||||||
|
dateLabel={dateLabel}
|
||||||
|
isToday={isToday}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0" aria-hidden />
|
||||||
|
<div className="min-w-0" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineDateCell({
|
||||||
|
dateLabel,
|
||||||
|
isToday,
|
||||||
|
}: {
|
||||||
|
dateLabel: string;
|
||||||
|
isToday: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
|
||||||
|
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isToday ? (
|
||||||
|
<>
|
||||||
|
<span className="duty-timeline-date-label text-today block leading-tight">
|
||||||
|
{t("duty.today")}
|
||||||
|
</span>
|
||||||
|
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
|
||||||
|
{dateLabel}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
dateLabel
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
194
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
194
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
|
||||||
|
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useRef } from "react";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import {
|
||||||
|
localDateString,
|
||||||
|
dateKeyToDDMM,
|
||||||
|
formatHHMM,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { DutyWithUser } from "@/types";
|
||||||
|
import { Phone, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export interface DutyTimelineCardProps {
|
||||||
|
duty: DutyWithUser;
|
||||||
|
isCurrent: boolean;
|
||||||
|
/** When provided, card is controlled: only one card can be flipped at a time (managed by parent). */
|
||||||
|
isFlipped?: boolean;
|
||||||
|
/** Called when user flips to contacts (true) or back (false). Used with isFlipped for controlled mode. */
|
||||||
|
onFlipChange?: (flipped: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimeStr(duty: DutyWithUser): string {
|
||||||
|
const startLocal = localDateString(new Date(duty.start_at));
|
||||||
|
const endLocal = localDateString(new Date(duty.end_at));
|
||||||
|
const startDDMM = dateKeyToDDMM(startLocal);
|
||||||
|
const endDDMM = dateKeyToDDMM(endLocal);
|
||||||
|
const startTime = formatHHMM(duty.start_at);
|
||||||
|
const endTime = formatHHMM(duty.end_at);
|
||||||
|
if (startLocal === endLocal) {
|
||||||
|
return `${startDDMM}, ${startTime} – ${endTime}`;
|
||||||
|
}
|
||||||
|
return `${startDDMM} ${startTime} – ${endDDMM} ${endTime}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cardBase =
|
||||||
|
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
|
||||||
|
const borderByType = {
|
||||||
|
duty: "border-l-duty",
|
||||||
|
unavailable: "border-l-unavailable",
|
||||||
|
vacation: "border-l-vacation",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a single duty card. If duty has phone or username, wraps in a flip card
|
||||||
|
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
|
||||||
|
*/
|
||||||
|
export function DutyTimelineCard({
|
||||||
|
duty,
|
||||||
|
isCurrent,
|
||||||
|
isFlipped,
|
||||||
|
onFlipChange,
|
||||||
|
}: DutyTimelineCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [localFlipped, setLocalFlipped] = useState(false);
|
||||||
|
const frontBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const backBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const hasContacts = Boolean(
|
||||||
|
(duty.phone && String(duty.phone).trim()) ||
|
||||||
|
(duty.username && String(duty.username).trim())
|
||||||
|
);
|
||||||
|
const typeLabel = isCurrent
|
||||||
|
? t("duty.now_on_duty")
|
||||||
|
: t(`event_type.${duty.event_type || "duty"}`);
|
||||||
|
const timeStr = useMemo(() => buildTimeStr(duty), [duty]);
|
||||||
|
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||||
|
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||||
|
|
||||||
|
const isControlled = onFlipChange != null;
|
||||||
|
const flipped = isControlled ? (isFlipped ?? false) : localFlipped;
|
||||||
|
|
||||||
|
const handleFlipToBack = () => {
|
||||||
|
if (isControlled) {
|
||||||
|
onFlipChange?.(true);
|
||||||
|
} else {
|
||||||
|
setLocalFlipped(true);
|
||||||
|
}
|
||||||
|
setTimeout(() => backBtnRef.current?.focus(), 310);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFlipToFront = () => {
|
||||||
|
if (isControlled) {
|
||||||
|
onFlipChange?.(false);
|
||||||
|
} else {
|
||||||
|
setLocalFlipped(false);
|
||||||
|
}
|
||||||
|
setTimeout(() => frontBtnRef.current?.focus(), 310);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!hasContacts) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
cardBase,
|
||||||
|
borderClass,
|
||||||
|
isCurrent && "bg-[var(--surface-today-tint)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||||
|
<span
|
||||||
|
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={duty.full_name ?? undefined}
|
||||||
|
>
|
||||||
|
{duty.full_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
|
||||||
|
<div
|
||||||
|
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
|
||||||
|
style={{
|
||||||
|
transformStyle: "preserve-3d",
|
||||||
|
transform: flipped ? "rotateY(180deg)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Front */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
cardBase,
|
||||||
|
borderClass,
|
||||||
|
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||||
|
"duty-flip-front"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
|
||||||
|
<span
|
||||||
|
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={duty.full_name ?? undefined}
|
||||||
|
>
|
||||||
|
{duty.full_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
|
||||||
|
<Button
|
||||||
|
ref={frontBtnRef}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||||
|
aria-label={t("contact.show")}
|
||||||
|
onClick={handleFlipToBack}
|
||||||
|
>
|
||||||
|
<Phone className="size-[18px]" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Back */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
cardBase,
|
||||||
|
borderClass,
|
||||||
|
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||||
|
"duty-flip-back"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
|
||||||
|
title={duty.full_name ?? undefined}
|
||||||
|
>
|
||||||
|
{duty.full_name}
|
||||||
|
</span>
|
||||||
|
<div className="row-start-2 mt-1">
|
||||||
|
<ContactLinks
|
||||||
|
phone={duty.phone}
|
||||||
|
username={duty.username}
|
||||||
|
layout="inline"
|
||||||
|
showLabels={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
ref={backBtnRef}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
|
||||||
|
aria-label={t("contact.back")}
|
||||||
|
onClick={handleFlipToFront}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-[18px]" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
webapp-next/src/components/duty/index.ts
Normal file
10
webapp-next/src/components/duty/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Duty list and timeline components.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { DutyList } from "./DutyList";
|
||||||
|
export { DutyTimelineCard } from "./DutyTimelineCard";
|
||||||
|
export { DutyItem } from "./DutyItem";
|
||||||
|
export type { DutyListProps } from "./DutyList";
|
||||||
|
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
|
||||||
|
export type { DutyItemProps } from "./DutyItem";
|
||||||
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import {
|
||||||
|
init,
|
||||||
|
mountMiniAppSync,
|
||||||
|
mountThemeParamsSync,
|
||||||
|
bindThemeParamsCssVars,
|
||||||
|
} from "@telegram-apps/sdk-react";
|
||||||
|
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the app with Telegram Mini App SDK initialization.
|
||||||
|
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||||
|
* and mounts the mini app. Does not call ready() here — the app calls
|
||||||
|
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
||||||
|
* has finished loading, so Telegram keeps its native loading animation until then.
|
||||||
|
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||||
|
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||||
|
*/
|
||||||
|
export function TelegramProvider({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
const cleanup = init({ acceptCustomStyles: true });
|
||||||
|
|
||||||
|
if (mountThemeParamsSync.isAvailable()) {
|
||||||
|
mountThemeParamsSync();
|
||||||
|
}
|
||||||
|
if (bindThemeParamsCssVars.isAvailable()) {
|
||||||
|
bindThemeParamsCssVars();
|
||||||
|
}
|
||||||
|
fixSurfaceContrast();
|
||||||
|
void document.documentElement.offsetHeight;
|
||||||
|
|
||||||
|
if (mountMiniAppSync.isAvailable()) {
|
||||||
|
mountMiniAppSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanup;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { AccessDenied } from "./AccessDenied";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("AccessDenied", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders translated access denied message", () => {
|
||||||
|
render(<AccessDenied serverDetail={null} />);
|
||||||
|
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends serverDetail when provided", () => {
|
||||||
|
render(<AccessDenied serverDetail="Custom 403 message" />);
|
||||||
|
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Access denied state: message and optional server detail.
|
||||||
|
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface AccessDeniedProps {
|
||||||
|
/** Optional detail from API 403 response, shown below the main message. */
|
||||||
|
serverDetail?: string | null;
|
||||||
|
/** Optional class for the container. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays access denied message; optional second paragraph for server detail.
|
||||||
|
*/
|
||||||
|
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<p className="m-0 mb-2 font-semibold text-error">
|
||||||
|
{t("access_denied")}
|
||||||
|
</p>
|
||||||
|
{hasDetail && (
|
||||||
|
<p className="mt-2 m-0 text-sm text-muted">
|
||||||
|
{serverDetail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 m-0 text-sm text-muted">
|
||||||
|
{t("access_denied.hint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { ErrorState } from "./ErrorState";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("ErrorState", () => {
|
||||||
|
beforeEach(() => resetAppStore());
|
||||||
|
|
||||||
|
it("renders error message", () => {
|
||||||
|
render(<ErrorState message="Network error" onRetry={undefined} />);
|
||||||
|
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders Retry button when onRetry provided", () => {
|
||||||
|
const onRetry = vi.fn();
|
||||||
|
render(<ErrorState message="Fail" onRetry={onRetry} />);
|
||||||
|
const retry = screen.getByRole("button", { name: /retry|повторить/i });
|
||||||
|
expect(retry).toBeInTheDocument();
|
||||||
|
retry.click();
|
||||||
|
expect(onRetry).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Error state: warning icon, message, and optional Retry button.
|
||||||
|
* Ported from webapp/js/ui.js showError and states.css .error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface ErrorStateProps {
|
||||||
|
/** Error message to display. If not provided, uses generic i18n message. */
|
||||||
|
message?: string | null;
|
||||||
|
/** Optional retry callback; when provided, a Retry button is shown. */
|
||||||
|
onRetry?: (() => void) | null;
|
||||||
|
/** Optional class for the container. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Warning triangle icon 24×24 for error state. */
|
||||||
|
function ErrorIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={cn("shrink-0 text-error", className)}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13" />
|
||||||
|
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays an error message with optional Retry button.
|
||||||
|
*/
|
||||||
|
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const displayMessage =
|
||||||
|
message && String(message).trim() ? message : t("error_generic");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<ErrorIcon />
|
||||||
|
<p className="m-0 text-sm font-medium">{displayMessage}</p>
|
||||||
|
{typeof onRetry === "function" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||||
|
onClick={onRetry}
|
||||||
|
>
|
||||||
|
{t("error.retry")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for LoadingState. Ported from webapp/js/ui.test.js (loading).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { LoadingState } from "./LoadingState";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("LoadingState", () => {
|
||||||
|
beforeEach(() => resetAppStore());
|
||||||
|
|
||||||
|
it("renders loading text", () => {
|
||||||
|
render(<LoadingState />);
|
||||||
|
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Loading state: spinner and translated "Loading…" text.
|
||||||
|
* Optionally wraps content in a container for calendar placeholder use.
|
||||||
|
* Ported from webapp CSS states.css .loading and index.html loading element.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface LoadingStateProps {
|
||||||
|
/** Optional class for the container. */
|
||||||
|
className?: string;
|
||||||
|
/** If true, render a compact skeleton-style placeholder (e.g. for calendar area). */
|
||||||
|
asPlaceholder?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spinner icon matching original .loading__spinner (accent color, reduced-motion safe).
|
||||||
|
*/
|
||||||
|
function LoadingSpinner({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block size-5 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none",
|
||||||
|
"animate-spin",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full loading view: flex center, spinner + "Loading…" text.
|
||||||
|
*/
|
||||||
|
export function LoadingState({ className, asPlaceholder }: LoadingStateProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (asPlaceholder) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[120px] items-center justify-center rounded-lg bg-muted/30",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("loading")}
|
||||||
|
>
|
||||||
|
<LoadingSpinner />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-2.5 py-3 text-center text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-label={t("loading")}
|
||||||
|
>
|
||||||
|
<LoadingSpinner />
|
||||||
|
<span className="loading__text">{t("loading")}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
webapp-next/src/components/states/index.ts
Normal file
7
webapp-next/src/components/states/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* State components: loading, error, access denied.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { LoadingState } from "./LoadingState";
|
||||||
|
export { ErrorState } from "./ErrorState";
|
||||||
|
export { AccessDenied } from "./AccessDenied";
|
||||||
48
webapp-next/src/components/ui/badge.tsx
Normal file
48
webapp-next/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span"> &
|
||||||
|
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||||
|
const Comp = asChild ? Slot.Root : "span"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="badge"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(badgeVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
64
webapp-next/src/components/ui/button.tsx
Normal file
64
webapp-next/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm": "size-8",
|
||||||
|
"icon-lg": "size-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
92
webapp-next/src/components/ui/card.tsx
Normal file
92
webapp-next/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
89
webapp-next/src/components/ui/popover.tsx
Normal file
89
webapp-next/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
}
|
||||||
163
webapp-next/src/components/ui/sheet.tsx
Normal file
163
webapp-next/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
forceMount,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
forceMount={forceMount}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
onCloseAnimationEnd,
|
||||||
|
onAnimationEnd,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
|
||||||
|
onCloseAnimationEnd?: () => void
|
||||||
|
}) {
|
||||||
|
const useForceMount = Boolean(onCloseAnimationEnd)
|
||||||
|
|
||||||
|
const handleAnimationEnd = React.useCallback(
|
||||||
|
(e: React.AnimationEvent<HTMLDivElement>) => {
|
||||||
|
onAnimationEnd?.(e)
|
||||||
|
if (e.currentTarget.getAttribute("data-state") === "closed") {
|
||||||
|
onCloseAnimationEnd?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAnimationEnd, onCloseAnimationEnd]
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay forceMount={useForceMount ? true : undefined} />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
forceMount={useForceMount ? true : undefined}
|
||||||
|
onAnimationEnd={handleAnimationEnd}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:duration-300",
|
||||||
|
side === "right" &&
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
side === "bottom" &&
|
||||||
|
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="skeleton"
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton }
|
||||||
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-surface fill-surface" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
63
webapp-next/src/hooks/use-app-init.ts
Normal file
63
webapp-next/src/hooks/use-app-init.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Application initialization: language sync, access-denied logic, deep link routing.
|
||||||
|
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { getLang } from "@/i18n/messages";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
|
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||||
|
|
||||||
|
export interface UseAppInitParams {
|
||||||
|
/** Whether the user is allowed (localhost or has valid initData). */
|
||||||
|
isAllowed: boolean;
|
||||||
|
/** Telegram Mini App start_param (e.g. "duty" for current duty deep link). */
|
||||||
|
startParam: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs language from backend config, applies document lang/title, handles access denied
|
||||||
|
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
||||||
|
*/
|
||||||
|
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||||
|
const setLang = useAppStore((s) => s.setLang);
|
||||||
|
const lang = useAppStore((s) => s.lang);
|
||||||
|
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||||
|
const setLoading = useAppStore((s) => s.setLoading);
|
||||||
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Sync lang from backend config (window.__DT_LANG).
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
setLang(getLang());
|
||||||
|
}, [setLang]);
|
||||||
|
|
||||||
|
// Apply lang to document (title and html lang) for accessibility and i18n.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.title = t("app.title");
|
||||||
|
}, [lang, t]);
|
||||||
|
|
||||||
|
// When not allowed (no initData and not localhost), show access denied after delay.
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAllowed) {
|
||||||
|
setAccessDenied(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
setAccessDenied(true);
|
||||||
|
setLoading(false);
|
||||||
|
}, RETRY_DELAY_MS);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [isAllowed, setAccessDenied, setLoading]);
|
||||||
|
|
||||||
|
// When opened via deep link startParam=duty, show current duty view first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (startParam === "duty") setCurrentView("currentDuty");
|
||||||
|
}, [startParam, setCurrentView]);
|
||||||
|
}
|
||||||
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* 60-second interval to refresh duty list when viewing the current month.
|
||||||
|
* Replaces state.todayRefreshInterval from webapp/js/main.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
|
||||||
|
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
||||||
|
*/
|
||||||
|
export function useAutoRefresh(
|
||||||
|
refresh: () => void,
|
||||||
|
isCurrentMonth: boolean
|
||||||
|
): void {
|
||||||
|
const refreshRef = useRef(refresh);
|
||||||
|
refreshRef.current = refresh;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCurrentMonth) return;
|
||||||
|
refreshRef.current();
|
||||||
|
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [isCurrentMonth]);
|
||||||
|
}
|
||||||
34
webapp-next/src/hooks/use-media-query.ts
Normal file
34
webapp-next/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Returns true when the given media query matches (e.g. min-width: 640px for desktop).
|
||||||
|
* Used to switch DayDetail between Popover (desktop) and Sheet (mobile).
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match a media query string (e.g. "(min-width: 640px)").
|
||||||
|
* Returns undefined during SSR to avoid hydration mismatch; client gets the real value.
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean | undefined {
|
||||||
|
const [matches, setMatches] = useState<boolean | undefined>(() =>
|
||||||
|
typeof window === "undefined" ? undefined : window.matchMedia(query).matches
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const mq = window.matchMedia(query);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
setMatches(mq.matches);
|
||||||
|
mq.addEventListener("change", handler);
|
||||||
|
return () => mq.removeEventListener("change", handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when viewport is at least 640px (desktop). Undefined during SSR. */
|
||||||
|
export function useIsDesktop(): boolean | undefined {
|
||||||
|
return useMediaQuery("(min-width: 640px)");
|
||||||
|
}
|
||||||
212
webapp-next/src/hooks/use-month-data.ts
Normal file
212
webapp-next/src/hooks/use-month-data.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* Fetches duties and calendar events for the current month. Handles loading, error,
|
||||||
|
* access denied, and retry after ACCESS_DENIED. Replaces loadMonth() from webapp/js/main.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
firstDayOfMonth,
|
||||||
|
lastDayOfMonth,
|
||||||
|
getMonday,
|
||||||
|
localDateString,
|
||||||
|
dutyOverlapsLocalRange,
|
||||||
|
} from "@/lib/date-utils";
|
||||||
|
import { RETRY_AFTER_ACCESS_DENIED_MS, RETRY_AFTER_ERROR_MS, MAX_GENERAL_RETRIES } from "@/lib/constants";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
import { translate } from "@/i18n/messages";
|
||||||
|
|
||||||
|
export interface UseMonthDataOptions {
|
||||||
|
/** Telegram init data string for API auth. When undefined, no fetch (unless isLocalhost). */
|
||||||
|
initDataRaw: string | undefined;
|
||||||
|
/** When true, fetch runs for the current month. When false, no fetch (e.g. access not allowed). */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches duties and calendar events for the displayed month when enabled.
|
||||||
|
* When pendingMonth is set (user clicked next/prev), loads that month without clearing
|
||||||
|
* current data; on success updates currentMonth and data in one batch (no empty-frame flicker).
|
||||||
|
* Cancels in-flight request when month changes or component unmounts.
|
||||||
|
* On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS.
|
||||||
|
* Returns retry() to manually trigger a reload.
|
||||||
|
*/
|
||||||
|
export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
|
||||||
|
const { initDataRaw, enabled } = options;
|
||||||
|
|
||||||
|
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||||
|
const pendingMonth = useAppStore((s) => s.pendingMonth);
|
||||||
|
const lang = useAppStore((s) => s.lang);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const initDataRetriedRef = useRef(false);
|
||||||
|
const generalRetryCountRef = useRef(0);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const optionsRef = useRef({ initDataRaw, enabled, lang });
|
||||||
|
optionsRef.current = { initDataRaw, enabled, lang };
|
||||||
|
|
||||||
|
const loadRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current;
|
||||||
|
if (!enabledOpt) {
|
||||||
|
useAppStore.getState().batchUpdate({ pendingMonth: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const initData = initDataRawOpt ?? "";
|
||||||
|
if (!initData && typeof window !== "undefined") {
|
||||||
|
const h = window.location.hostname;
|
||||||
|
if (h !== "localhost" && h !== "127.0.0.1" && h !== "") {
|
||||||
|
useAppStore.getState().batchUpdate({ pendingMonth: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = useAppStore.getState();
|
||||||
|
const pending = store.pendingMonth;
|
||||||
|
const monthToLoad = pending ?? store.currentMonth;
|
||||||
|
const monthKey = `${monthToLoad.getFullYear()}-${String(monthToLoad.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const dataForMonthKey = store.dataForMonthKey;
|
||||||
|
const isDeferredSwitch = pending !== null;
|
||||||
|
const isNewMonth = !isDeferredSwitch && dataForMonthKey !== monthKey;
|
||||||
|
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
const signal = abortRef.current.signal;
|
||||||
|
|
||||||
|
store.batchUpdate({
|
||||||
|
accessDenied: false,
|
||||||
|
accessDeniedDetail: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
...(isNewMonth
|
||||||
|
? { duties: [], calendarEvents: [], dataForMonthKey: null }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = firstDayOfMonth(monthToLoad);
|
||||||
|
const start = getMonday(first);
|
||||||
|
const gridEnd = new Date(start);
|
||||||
|
gridEnd.setDate(gridEnd.getDate() + 41);
|
||||||
|
const from = localDateString(start);
|
||||||
|
const to = localDateString(gridEnd);
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
logger.debug("Loading month", from, to);
|
||||||
|
const [duties, events] = await Promise.all([
|
||||||
|
fetchDuties(from, to, initData, langOpt, signal),
|
||||||
|
fetchCalendarEvents(from, to, initData, langOpt, signal),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const last = lastDayOfMonth(monthToLoad);
|
||||||
|
const firstKey = localDateString(first);
|
||||||
|
const lastKey = localDateString(last);
|
||||||
|
const dutiesInMonth = duties.filter((d) =>
|
||||||
|
dutyOverlapsLocalRange(d, firstKey, lastKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
const storeAfter = useAppStore.getState();
|
||||||
|
const switchedMonth =
|
||||||
|
storeAfter.currentMonth.getFullYear() !== monthToLoad.getFullYear() ||
|
||||||
|
storeAfter.currentMonth.getMonth() !== monthToLoad.getMonth();
|
||||||
|
|
||||||
|
useAppStore.getState().batchUpdate({
|
||||||
|
...(switchedMonth
|
||||||
|
? { currentMonth: new Date(monthToLoad.getFullYear(), monthToLoad.getMonth(), 1) }
|
||||||
|
: {}),
|
||||||
|
pendingMonth: null,
|
||||||
|
duties: dutiesInMonth,
|
||||||
|
calendarEvents: events,
|
||||||
|
dataForMonthKey: monthKey,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === "AbortError") {
|
||||||
|
useAppStore.getState().batchUpdate({
|
||||||
|
loading: false,
|
||||||
|
pendingMonth: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e instanceof AccessDeniedError) {
|
||||||
|
logger.warn("Access denied in loadMonth", e.serverDetail);
|
||||||
|
useAppStore.getState().batchUpdate({
|
||||||
|
accessDenied: true,
|
||||||
|
accessDeniedDetail: e.serverDetail ?? null,
|
||||||
|
loading: false,
|
||||||
|
pendingMonth: null,
|
||||||
|
});
|
||||||
|
if (!initDataRetriedRef.current) {
|
||||||
|
initDataRetriedRef.current = true;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (mountedRef.current) loadRef.current();
|
||||||
|
}, RETRY_AFTER_ACCESS_DENIED_MS);
|
||||||
|
retryTimeoutRef.current = timeoutId;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error("Load month failed", e);
|
||||||
|
if (generalRetryCountRef.current < MAX_GENERAL_RETRIES && mountedRef.current) {
|
||||||
|
generalRetryCountRef.current++;
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (mountedRef.current) loadRef.current();
|
||||||
|
}, RETRY_AFTER_ERROR_MS);
|
||||||
|
retryTimeoutRef.current = timeoutId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useAppStore.getState().batchUpdate({
|
||||||
|
error: translate(langOpt, "error_generic"),
|
||||||
|
loading: false,
|
||||||
|
pendingMonth: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
loadRef.current = load;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
initDataRetriedRef.current = false;
|
||||||
|
generalRetryCountRef.current = 0;
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
if (retryTimeoutRef.current) {
|
||||||
|
clearTimeout(retryTimeoutRef.current);
|
||||||
|
retryTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
if (abortRef.current) abortRef.current.abort();
|
||||||
|
abortRef.current = null;
|
||||||
|
};
|
||||||
|
}, [enabled, load, currentMonth, pendingMonth, lang, initDataRaw]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState !== "visible") return;
|
||||||
|
const { duties, loading: isLoading, error: hasError } = useAppStore.getState();
|
||||||
|
if (duties.length === 0 && !isLoading && !hasError) {
|
||||||
|
generalRetryCountRef.current = 0;
|
||||||
|
loadRef.current();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", handleVisibility);
|
||||||
|
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
||||||
|
}, [enabled]);
|
||||||
|
|
||||||
|
return { retry: load };
|
||||||
|
}
|
||||||
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Toggles an "is-scrolled" class on the sticky element when the user has scrolled.
|
||||||
|
* Replaces bindStickyScrollShadow from webapp/js/main.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
const IS_SCROLLED_CLASS = "is-scrolled";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens to window scroll and toggles the class "is-scrolled" on the given element
|
||||||
|
* when window.scrollY > 0. Uses passive scroll listener.
|
||||||
|
*/
|
||||||
|
export function useStickyScroll(
|
||||||
|
elementRef: React.RefObject<HTMLElement | null>
|
||||||
|
): void {
|
||||||
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = elementRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
rafRef.current = null;
|
||||||
|
const scrolled = typeof window !== "undefined" && window.scrollY > 0;
|
||||||
|
el.classList.toggle(IS_SCROLLED_CLASS, scrolled);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (rafRef.current == null) {
|
||||||
|
rafRef.current = requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", handleScroll);
|
||||||
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [elementRef]);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user