feat: migrate to Next.js for Mini App and enhance project structure

- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability.
- Updated the `.gitignore` to exclude Next.js build artifacts and node modules.
- Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack.
- Enhanced Dockerfile to support the new build process for the Next.js application.
- Updated CI workflow to build and test the Next.js application.
- Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking.
- Refactored frontend testing setup to accommodate the new structure and testing framework.
- Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
2026-03-03 16:04:08 +03:00
parent 2de5c1cb81
commit 16bf1a1043
148 changed files with 20240 additions and 7270 deletions

View File

@@ -1,93 +1,53 @@
--- ---
description: Rules for working with the Telegram Mini App frontend (webapp/) description: Rules for working with the Telegram Mini App frontend (webapp-next/)
globs: globs:
- webapp/** - webapp-next/**
--- ---
# Frontend — Telegram Mini App # Frontend — Telegram Mini App (Next.js)
## Module structure The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
All source lives in `webapp/js/`. Each module has a single responsibility: ## Stack
| Module | Responsibility | - **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|--------|---------------| - **TypeScript**
| `main.js` | Entry point: theme init, auth gate, `loadMonth`, navigation, swipe gestures, sticky scroll | - **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
| `dom.js` | Lazy DOM getters (`getCalendarEl()`, `getDutyListEl()`, etc.) and shared mutable `state` | - **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
| `i18n.js` | `MESSAGES` dictionary (en/ru), `getLang()`, `t()`, `monthName()`, `weekdayLabels()` | - **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
| `auth.js` | Telegram `initData` extraction (`getInitData`), `isLocalhost`, hash/query fallback | - **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
| `theme.js` | Theme detection (`getTheme`), CSS variable injection (`applyThemeParamsToCss`), `initTheme` |
| `api.js` | `apiGet`, `fetchDuties`, `fetchCalendarEvents`; timeout, auth header, `Accept-Language` |
| `calendar.js` | `dutiesByDate`, `calendarEventsByDate`, `renderCalendar` (6-week grid with indicators) |
| `dutyList.js` | `dutyTimelineCardHtml`, `dutyItemHtml`, `renderDutyList` (monthly duty timeline cards) |
| `dayDetail.js` | Day detail panel — popover (desktop) or bottom sheet (mobile), `buildDayDetailContent` |
| `hints.js` | Tooltip positioning, duty marker hint content, info-button tooltips |
| `dateUtils.js` | Date helpers: `localDateString`, `dutyOverlapsLocalDay/Range`, `getMonday`, `formatHHMM`, etc. |
| `utils.js` | `escapeHtml` utility |
| `constants.js` | `FETCH_TIMEOUT_MS`, `RETRY_DELAY_MS`, `RETRY_AFTER_ACCESS_DENIED_MS` |
## State management ## Structure
A single mutable `state` object is exported from `dom.js`: | 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` |
```js ## Conventions
export const state = {
current: new Date(), // currently displayed month
lastDutiesForList: [], // duties array for the duty list
todayRefreshInterval: null, // interval handle
lang: "en" // 'ru' | 'en'
};
```
- **No store / pub-sub / reactivity.** `main.js` mutates `state`, then calls - **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
render functions (`renderCalendar`, `renderDutyList`) imperatively. - **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
- Other modules read `state` but should not mutate it directly. - **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.
## HTML rendering ## Testing
- Rendering functions build HTML strings (concatenation + `escapeHtml`) or use - **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
`createElement` + `setAttribute`. - **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
- Always escape user-controlled text with `escapeHtml()` before inserting via `innerHTML`. - **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
- `setAttribute()` handles attribute quoting automatically — do not manually escape - **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
quotes for data attributes.
## i18n Consider these rules when changing the Mini App or adding frontend features.
```js
t(lang, key, params?) // → translated string
```
- `lang` is `'ru'` or `'en'`, stored in `state.lang`.
- `getLang()` reads **backend config** only: `window.__DT_LANG` (set by `/app/config.js` from `DEFAULT_LANGUAGE`). If missing or invalid, falls back to `"en"`. No Telegram or navigator language detection.
- Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`.
- Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string.
- All user-visible text must go through `t()` — never hardcode Russian strings in JS.
## Telegram WebApp SDK
- Loaded via `<script>` in `index.html` from `https://telegram.org/js/telegram-web-app.js`.
- `main.js` calls `Telegram.WebApp.ready()` and `expand()` on startup.
- Auth: `initData` from SDK → hash param `tgWebAppData` → query string (fallback chain in `auth.js`).
- The `X-Telegram-Init-Data` header carries initData to the backend API.
- `requireTelegramOrLocalhost` gates access; localhost is allowed for dev.
- Theme changes subscribed via `Telegram.WebApp.onEvent("theme_changed", applyTheme)`.
## Theme system
- CSS variables in `:root`: `--bg`, `--surface`, `--text`, `--muted`, `--accent`,
`--duty`, `--today`, `--unavailable`, `--vacation`, `--error`, etc.
- `[data-theme="light"]` and `[data-theme="dark"]` override base variables.
- `theme.js` resolves scheme via:
1. `Telegram.WebApp.colorScheme`
2. CSS `--tg-color-scheme`
3. `prefers-color-scheme` media query
- `applyThemeParamsToCss()` maps Telegram `themeParams` to `--tg-theme-*` CSS variables.
- Prefer `var(--token)` over hardcoded colors; use `color-mix()` for alpha variants.
## Testing (frontend)
- **Runner:** Vitest (`webapp/vitest.config.js`), environment: `happy-dom`.
- **Test files:** `webapp/js/**/*.test.js`.
- **DOM setup:** Element refs are resolved lazily (getters). Set `document.body.innerHTML`
with required DOM structure in `beforeAll`/`beforeEach`; import order of modules using `dom.js` is flexible.
- **Run:** `npm run test` (in `webapp/`).

View File

@@ -20,7 +20,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
┌──────────────┐ HTTP ┌──────────▼───────────┐ ┌──────────────┐ HTTP ┌──────────▼───────────┐
│ Telegram │◄────────────►│ FastAPI (api/) │ │ Telegram │◄────────────►│ FastAPI (api/) │
│ Mini App │ initData │ + static webapp │ │ Mini App │ initData │ + static webapp │
│ (webapp/) │ auth └──────────┬───────────┘ │ (webapp-next/) │ auth └──────────┬───────────┘
└──────────────┘ │ └──────────────┘ │
┌──────────▼───────────┐ ┌──────────▼───────────┐
│ SQLite + SQLAlchemy │ │ SQLite + SQLAlchemy │
@@ -31,7 +31,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
- **Bot:** python-telegram-bot v22, async polling mode. - **Bot:** python-telegram-bot v22, async polling mode.
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot. - **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations. - **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
- **Frontend:** Vanilla JS Telegram Mini App at `/app`, no framework. - **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
## Key packages ## Key packages
@@ -46,7 +46,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client | | `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation | | `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
| `duty_teller/config.py` | Environment-based configuration | | `duty_teller/config.py` | Environment-based configuration |
| `webapp/` | Telegram Mini App (HTML + JS + CSS) | | `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
## API endpoints ## API endpoints
@@ -103,7 +103,7 @@ Triggered on `v*` tags:
## Languages ## Languages
- **Backend:** Python 3.12+ - **Backend:** Python 3.12+
- **Frontend:** Vanilla JavaScript (ES modules, no bundler) - **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
- **i18n:** Russian (default) and English - **i18n:** Russian (default) and English
## Version control ## Version control

View File

@@ -2,7 +2,7 @@
description: Rules for writing and running tests description: Rules for writing and running tests
globs: globs:
- tests/** - tests/**
- webapp/js/**/*.test.js - webapp-next/src/**/*.test.{ts,tsx}
--- ---
# Testing # Testing
@@ -51,48 +51,28 @@ def test_get_or_create_user_creates_new(test_db_url):
assert user.telegram_user_id == 123 assert user.telegram_user_id == 123
``` ```
## JavaScript tests (Vitest) ## Frontend tests (Vitest + React Testing Library)
### Configuration ### Configuration
- Config: `webapp/vitest.config.js`. - Config: `webapp-next/vitest.config.ts`.
- Environment: `happy-dom` (lightweight DOM implementation). - Environment: jsdom; React Testing Library for components.
- Test files: `webapp/js/**/*.test.js`. - Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
- Run: `npm run test` (from `webapp/`). - Setup: `webapp-next/src/test/setup.ts`.
- Run: `cd webapp-next && npm test` (or `npm run test`).
### DOM setup ### Writing frontend tests
Modules that import from `dom.js` expect DOM elements to exist at import time. - Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
Use `beforeAll` to set up the required HTML structure before the first import: - 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.
```js - File naming: `<module>.test.ts` or `<Component>.test.tsx`.
import { beforeAll, describe, it, expect } from "vitest";
beforeAll(() => {
document.body.innerHTML = `
<div id="calendar"></div>
<h2 id="monthTitle"></h2>
<button id="prevBtn"></button>
<button id="nextBtn"></button>
<div id="dutyList"></div>
<div id="dayDetail"></div>
<div id="accessDenied" class="hidden"></div>
<div id="errorBanner" class="hidden"></div>
`;
});
```
### Writing JS tests
- File naming: `webapp/js/<module>.test.js` (co-located with the source module).
- Test pure functions first (`dateUtils`, `i18n`, `utils`); mock DOM for render tests.
- Use `describe` blocks to group by function, `it` blocks for individual cases.
### Example structure ### Example structure
```js ```ts
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { localDateString } from "./dateUtils.js"; import { localDateString } from "./date-utils";
describe("localDateString", () => { describe("localDateString", () => {
it("formats date as YYYY-MM-DD", () => { it("formats date as YYYY-MM-DD", () => {

View File

@@ -45,8 +45,6 @@ jobs:
with: with:
node-version: "20" node-version: "20"
- name: Webapp tests - name: Webapp (Next.js) build and test
run: | run: |
cd webapp cd webapp-next && npm ci && npm test && npm run build
npm ci
npm run test

7
.gitignore vendored
View File

@@ -16,4 +16,9 @@ htmlcov/
*.plan.md *.plan.md
# Logs # Logs
*.log *.log
# Next.js webapp
webapp-next/out/
webapp-next/node_modules/
webapp-next/.next/

View File

@@ -4,7 +4,7 @@ This file is for AI assistants (e.g. Cursor) and maintainers. All project docume
## Project summary ## 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), Vanilla JS webapp. The bot and web UI support Russian and English; configuration and docs are in English. 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 ## Key entry points
@@ -23,12 +23,12 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
| Translations (ru/en) | `duty_teller/i18n/` | | Translations (ru/en) | `duty_teller/i18n/` |
| Duty-schedule parser | `duty_teller/importers/` | | Duty-schedule parser | `duty_teller/importers/` |
| Config (env vars) | `duty_teller/config.py` | | Config (env vars) | `duty_teller/config.py` |
| Miniapp frontend | `webapp/` | | Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) | | Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
## Running and testing ## 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. - **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` - **Lint:** `ruff check duty_teller tests`
- **Security:** `bandit -r duty_teller -ll` - **Security:** `bandit -r duty_teller -ll`

View File

@@ -1,14 +1,22 @@
# Multi-stage: builder installs deps; runtime copies only site-packages and app code. # Multi-stage: webapp build (Next.js), Python builder, runtime.
# Single image for both dev and prod; Compose files differentiate behavior. # 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 \

View File

@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
- `main.py` Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing. - `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 .`).

View File

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

View File

@@ -7,6 +7,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
| **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. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. 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 (165535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. | | **HTTP_PORT** | integer (165535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). | | **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`). |

View File

@@ -128,18 +128,32 @@ def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
return default 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.get(
"/app/config.js", "/app/config.js",
summary="Mini App config (language, log level)", summary="Mini App config (language, log level, timezone)",
description=( description=(
"Returns JS that sets window.__DT_LANG and window.__DT_LOG_LEVEL. Loaded before main.js." "Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
"Loaded before main.js."
), ),
) )
def app_config_js() -> Response: def app_config_js() -> Response:
"""Return JS assigning window.__DT_LANG and window.__DT_LOG_LEVEL for the webapp. No caching.""" """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") lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info") log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
body = f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";' 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( return Response(
content=body, content=body,
media_type="application/javascript; charset=utf-8", media_type="application/javascript; charset=utf-8",
@@ -267,6 +281,7 @@ 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")

View File

@@ -111,6 +111,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]
@@ -168,6 +169,7 @@ class Settings:
database_url=database_url, 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=http_port, http_port=http_port,
allowed_usernames=allowed, allowed_usernames=allowed,
@@ -197,6 +199,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

View File

@@ -87,10 +87,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,

View File

@@ -90,10 +90,12 @@ 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 +109,21 @@ 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."""

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

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

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

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,52 @@
/**
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
*/
"use client";
import "./globals.css";
import { getLang, translate } from "@/i18n/messages";
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const lang = getLang();
return (
<html
lang={lang === "ru" ? "ru" : "en"}
data-theme="dark"
suppressHydrationWarning
>
<head>
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
</head>
<body className="antialiased">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")}
</h1>
<p className="text-center text-muted-foreground">
{translate(lang, "error_boundary.description")}
</p>
<button
type="button"
onClick={() => reset()}
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{translate(lang, "error_boundary.reload")}
</button>
</div>
</body>
</html>
);
}

View File

@@ -0,0 +1,287 @@
@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: flip card (front = duty info, back = contacts) */
.duty-flip-card {
perspective: 600px;
}
.duty-flip-inner {
transform-style: preserve-3d;
}
.duty-flip-front {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.duty-flip-back {
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
position: absolute;
inset: 0;
transform: rotateY(180deg);
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Safe area for Telegram Mini App (notch / status bar). */
.pt-safe {
padding-top: env(safe-area-inset-top, 0);
}
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
.sticky.is-scrolled {
box-shadow: 0 1px 0 0 var(--border);
}
/* Calendar grid: 6 rows with minimum height so cells stay large (restore pre-audit look). */
.calendar-grid {
grid-template-rows: repeat(6, minmax(var(--calendar-row-min-height), 1fr));
}
/* Current duty card: entrance animation (respects prefers-reduced-motion via global rule). */
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.current-duty-card {
box-shadow: var(--shadow-card);
animation: card-appear 0.3s var(--ease-out);
}
.current-duty-card--no-duty {
border-top-color: var(--muted);
}
@layer base {
* {
box-sizing: border-box;
@apply border-border outline-ring/50;
}
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
-webkit-tap-highlight-color: transparent;
}
}

View File

@@ -0,0 +1,48 @@
import type { Metadata, Viewport } from "next";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TelegramProvider } from "@/components/providers/TelegramProvider";
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
import "./globals.css";
export const metadata: Metadata = {
title: "Duty Teller",
description: "Team duty shift calendar and reminders",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" data-theme="dark" suppressHydrationWarning>
<head>
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
}}
/>
<script
dangerouslySetInnerHTML={{
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
}}
/>
<script src="/app/config.js" />
</head>
<body className="antialiased">
<TelegramProvider>
<AppErrorBoundary>
<TooltipProvider>{children}</TooltipProvider>
</AppErrorBoundary>
</TelegramProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Next.js 404 page. Shown when notFound() is called or route is unknown.
* For static export with a single route this is rarely hit; added for consistency.
*/
"use client";
import Link from "next/link";
import { useTranslation } from "@/i18n/use-translation";
export default function NotFound() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p>
<Link
href="/"
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
>
{t("not_found.open_calendar")}
</Link>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/**
* Integration test for main page: calendar and header visible, lang from store.
* Ported from webapp/js/main.test.js applyLangToUi.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import Page from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
}));
vi.mock("@/hooks/use-month-data", () => ({
useMonthData: () => ({
retry: vi.fn(),
}),
}));
describe("Page", () => {
beforeEach(() => {
resetAppStore();
});
it("renders calendar and header when store has default state", async () => {
render(<Page />);
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
});
it("sets document title and lang from store lang", async () => {
useAppStore.getState().setLang("en");
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
expect(document.title).toBe("Duty Calendar");
expect(document.documentElement.lang).toBe("en");
});
it("sets document title for ru when store lang is ru", async () => {
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
render(<Page />);
await screen.findByRole("grid", { name: "Calendar" });
await waitFor(() => {
expect(document.title).toBe("Календарь дежурств");
});
});
});

View File

@@ -0,0 +1,50 @@
/**
* 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 } 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 { 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 } = useAppStore(
useShallow((s) => ({
currentView: s.currentView,
setCurrentView: s.setCurrentView,
setSelectedDay: s.setSelectedDay,
}))
);
const handleBackFromCurrentDuty = useCallback(() => {
setCurrentView("calendar");
setSelectedDay(null);
}, [setCurrentView, setSelectedDay]);
if (currentView === "currentDuty") {
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">
<CurrentDutyView
onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"}
/>
</div>
);
}
return <CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />;
}

View File

@@ -0,0 +1,76 @@
/**
* Error boundary that catches render errors in the app tree and shows a fallback
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
* on React context that might be broken.
*/
"use client";
import React from "react";
import { getLang } from "@/i18n/messages";
import { translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
interface AppErrorBoundaryProps {
children: React.ReactNode;
}
interface AppErrorBoundaryState {
hasError: boolean;
}
/**
* Catches JavaScript errors in the child tree and renders a fallback UI
* instead of crashing. Provides a Reload button to recover.
*/
export class AppErrorBoundary extends React.Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
constructor(props: AppErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
if (typeof console !== "undefined" && console.error) {
console.error("AppErrorBoundary caught an error:", error, errorInfo);
}
}
handleReload = (): void => {
if (typeof window !== "undefined") {
window.location.reload();
}
};
render(): React.ReactNode {
if (this.state.hasError) {
const lang = getLang();
const message = translate(lang, "error_boundary.message");
const reloadLabel = translate(lang, "error_boundary.reload");
return (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
role="alert"
>
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
<Button
type="button"
variant="default"
size="sm"
onClick={this.handleReload}
className="bg-primary text-primary-foreground hover:opacity-90"
>
{reloadLabel}
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,195 @@
/**
* 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, DutyListSkeleton } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { LoadingState } from "@/components/states/LoadingState";
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,
loading,
error,
accessDenied,
accessDeniedDetail,
duties,
calendarEvents,
selectedDay,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
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,
}))
);
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 handleGoToToday = useCallback(() => {
setCurrentMonth(new Date());
retry();
}, [setCurrentMonth, retry]);
const isInitialLoad =
loading && duties.length === 0 && calendarEvents.length === 0;
// Signal Telegram to hide loading when calendar first load finishes.
useEffect(() => {
if (!isInitialLoad) {
callMiniAppReadyOnce();
}
}, [isInitialLoad]);
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}
isLoading={loading}
disabled={navDisabled}
onGoToToday={handleGoToToday}
onRefresh={retry}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
{isInitialLoad ? (
<LoadingState
asPlaceholder
className="min-h-[var(--calendar-block-min-height)]"
/>
) : (
<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 && loading && !isInitialLoad ? (
<DutyListSkeleton className="mt-2" />
) : !accessDenied && !error && !isInitialLoad ? (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
) : null}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Unit tests for CalendarDay: click opens day detail only for current month;
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CalendarDay } from "./CalendarDay";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarDay", () => {
const defaultProps = {
dateKey: "2025-02-15",
dayOfMonth: 15,
isToday: false,
duties: [],
eventSummaries: [],
onDayClick: () => {},
};
beforeEach(() => {
resetAppStore();
});
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={false}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).toHaveBeenCalledTimes(1);
expect(onDayClick).toHaveBeenCalledWith(
"2025-02-15",
expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
top: expect.any(Number),
left: expect.any(Number),
})
);
});
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={true}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).not.toHaveBeenCalled();
});
it("sets aria-disabled on the button when isOtherMonth is true", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("is not disabled for interaction when isOtherMonth is false", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button.getAttribute("aria-disabled")).not.toBe("true");
});
});

View File

@@ -0,0 +1,123 @@
/**
* Single calendar day cell: date number and day indicators. Click opens day detail.
* Ported from webapp/js/calendar.js day cell rendering.
*/
"use client";
import React, { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { dateKeyToDDMM } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
import { DayIndicators } from "./DayIndicators";
export interface CalendarDayProps {
/** YYYY-MM-DD key for this day. */
dateKey: string;
/** Day of month (131) for display. */
dayOfMonth: number;
isToday: boolean;
isOtherMonth: boolean;
/** Duties overlapping this day (for indicators and tooltip). */
duties: DutyWithUser[];
/** External calendar event summaries for this day. */
eventSummaries: string[];
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
}
function CalendarDayInner({
dateKey,
dayOfMonth,
isToday,
isOtherMonth,
duties,
eventSummaries,
onDayClick,
}: CalendarDayProps) {
const { t } = useTranslation();
const { dutyList, unavailableList, vacationList } = useMemo(
() => ({
dutyList: duties.filter((d) => d.event_type === "duty"),
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
vacationList: duties.filter((d) => d.event_type === "vacation"),
}),
[duties]
);
const hasEvent = eventSummaries.length > 0;
const showIndicator = !isOtherMonth;
const hasAny = duties.length > 0 || hasEvent;
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
if (hasAny && showIndicator) {
const counts: string[] = [];
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
if (unavailableList.length)
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
if (vacationList.length)
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
if (hasEvent) counts.push(t("hint.events"));
ariaParts.push(counts.join(", "));
} else {
ariaParts.push(t("aria.day_info"));
}
const ariaLabel = ariaParts.join("; ");
const content = (
<button
type="button"
aria-label={ariaLabel}
aria-disabled={isOtherMonth}
data-date={dateKey}
className={cn(
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
isOtherMonth &&
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
!isOtherMonth && [
"bg-surface hover:bg-[var(--surface-hover-10)]",
"active:scale-[0.98] cursor-pointer",
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
],
showIndicator && hasAny && "font-bold",
showIndicator &&
hasEvent &&
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
isToday &&
hasEvent &&
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
)}
onClick={(e) => {
if (isOtherMonth) return;
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
}}
>
<span className="num">{dayOfMonth}</span>
{showIndicator && (duties.length > 0 || hasEvent) && (
<DayIndicators
dutyCount={dutyList.length}
unavailableCount={unavailableList.length}
vacationCount={vacationList.length}
hasEvents={hasEvent}
isToday={isToday}
/>
)}
</button>
);
return content;
}
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
return (
prev.dateKey === next.dateKey &&
prev.dayOfMonth === next.dayOfMonth &&
prev.isToday === next.isToday &&
prev.isOtherMonth === next.isOtherMonth &&
prev.duties === next.duties &&
prev.eventSummaries === next.eventSummaries &&
prev.onDayClick === next.onDayClick
);
}
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);

View File

@@ -0,0 +1,88 @@
/**
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
* Ported from webapp/js/calendar.test.js renderCalendar.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { CalendarGrid } from "./CalendarGrid";
import { CalendarHeader } from "./CalendarHeader";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarGrid", () => {
beforeEach(() => {
resetAppStore();
});
it("renders 42 cells (6 weeks)", () => {
const currentMonth = new Date(2025, 0, 1); // January 2025
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const cells = screen.getAllByRole("button", { name: /;/ });
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
const currentMonth = new Date(2025, 0, 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const grid = screen.getByRole("grid", { name: "Calendar" });
const buttons = grid.querySelectorAll('button[data-date]');
expect(buttons.length).toBe(42);
buttons.forEach((el) => {
const date = el.getAttribute("data-date");
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
it("adds today styling to cell matching today", () => {
const today = new Date();
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const todayKey = `${year}-${month}-${day}`;
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
expect(todayCell).toBeTruthy();
expect(todayCell?.className).toMatch(/today|bg-today/);
});
});
describe("CalendarHeader", () => {
beforeEach(() => {
resetAppStore();
});
it("sets month title from lang and given year/month", () => {
render(
<CalendarHeader
month={new Date(2025, 1, 1)}
onPrevMonth={() => {}}
onNextMonth={() => {}}
/>
);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("February");
expect(heading).toHaveTextContent("2025");
});
});

View File

@@ -0,0 +1,93 @@
/**
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
* Ported from webapp/js/calendar.js renderCalendar.
*/
"use client";
import { useMemo } from "react";
import {
firstDayOfMonth,
getMonday,
localDateString,
} from "@/lib/date-utils";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { cn } from "@/lib/utils";
import { CalendarDay } from "./CalendarDay";
export interface CalendarGridProps {
/** Currently displayed month. */
currentMonth: Date;
/** All duties for the visible range (will be grouped by date). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
className?: string;
}
const CELLS = 42;
export function CalendarGrid({
currentMonth,
duties,
calendarEvents,
onDayClick,
className,
}: CalendarGridProps) {
const dutiesByDateMap = useMemo(
() => dutiesByDate(duties),
[duties]
);
const calendarEventsByDateMap = useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const todayKey = localDateString(new Date());
const cells = useMemo(() => {
const first = firstDayOfMonth(currentMonth);
const start = getMonday(first);
const result: { date: Date; key: string; month: number }[] = [];
const d = new Date(start);
for (let i = 0; i < CELLS; i++) {
const key = localDateString(d);
result.push({ date: new Date(d), key, month: d.getMonth() });
d.setDate(d.getDate() + 1);
}
return result;
}, [currentMonth]);
return (
<div
className={cn(
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
className
)}
role="grid"
aria-label="Calendar"
>
{cells.map(({ date, key, month }) => {
const isOtherMonth = month !== currentMonth.getMonth();
const dayDuties = dutiesByDateMap[key] ?? [];
const eventSummaries = calendarEventsByDateMap[key] ?? [];
return (
<div key={key} role="gridcell" className="min-h-0">
<CalendarDay
dateKey={key}
dayOfMonth={date.getDate()}
isToday={key === todayKey}
isOtherMonth={isOtherMonth}
duties={dayDuties}
eventSummaries={eventSummaries}
onDayClick={onDayClick}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,147 @@
/**
* 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,
RefreshCw as RefreshCwIcon,
} 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;
/** When true, show a compact loading spinner next to the month title (e.g. while fetching new month). */
isLoading?: boolean;
/** When provided and displayed month is not the current month, show a "Today" control that calls this. */
onGoToToday?: () => void;
/** When provided, show a refresh icon that calls this (e.g. to refetch month data). */
onRefresh?: () => void;
onPrevMonth: () => void;
onNextMonth: () => void;
className?: string;
}
const HeaderSpinner = () => (
<span
className="block size-4 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
);
function isCurrentMonth(month: Date): boolean {
const now = new Date();
return (
month.getFullYear() === now.getFullYear() &&
month.getMonth() === now.getMonth()
);
}
export function CalendarHeader({
month,
disabled = false,
isLoading = false,
onGoToToday,
onRefresh,
onPrevMonth,
onNextMonth,
className,
}: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
const labels = weekdayLabels();
const showToday = Boolean(onGoToToday) && !isCurrentMonth(month);
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}
{isLoading && (
<span
role="status"
aria-live="polite"
aria-label={t("loading")}
className="flex items-center"
>
<HeaderSpinner />
</span>
)}
</h1>
{showToday && (
<button
type="button"
onClick={onGoToToday}
disabled={disabled}
className="text-[0.8rem] font-medium text-accent hover:underline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2 disabled:opacity-50"
aria-label={t("nav.today")}
>
{t("nav.today")}
</button>
)}
</div>
<div className="flex items-center gap-1">
{onRefresh && (
<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.refresh")}
disabled={disabled || isLoading}
onClick={onRefresh}
>
<RefreshCwIcon
className={cn("size-5", isLoading && "motion-reduce:animate-none animate-spin")}
aria-hidden
/>
</Button>
)}
<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>
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
{labels.map((label, i) => (
<span key={i} aria-hidden>
{label}
</span>
))}
</div>
</header>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Unit tests for DayIndicators: rounding is position-based (first / last / only child),
* not by indicator type, so 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 with only-child rounding (e.g. vacation only)", () => {
const { container } = render(
<DayIndicators {...baseProps} vacationCount={1} />
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain("[&>:only-child]:rounded-full");
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:not(:only-child)]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child:not(:only-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:not(:only-child)]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child:not(:only-child)]:rounded-r-[3px]"
);
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(3);
});
});

View File

@@ -0,0 +1,66 @@
/**
* Colored dots 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 / only child), not by indicator type, so multiple
* segments form one "pill": only the left and right ends are rounded.
*/
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 dots 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",
"[&>:only-child]:h-1.5 [&>:only-child]:min-w-[6px] [&>:only-child]:max-w-[6px] [&>:only-child]:rounded-full",
"[&>:first-child:not(:only-child)]:rounded-l-[3px]",
"[&>:last-child:not(:only-child)]:rounded-r-[3px]",
className
)}
aria-hidden
>
{dutyCount > 0 && <span className={dotClass("duty")} />}
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
{vacationCount > 0 && <span className={dotClass("vacation")} />}
{hasEvents && <span className={dotClass("events")} />}
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Calendar components and data helpers.
*/
export { CalendarGrid } from "./CalendarGrid";
export { CalendarHeader } from "./CalendarHeader";
export { CalendarDay } from "./CalendarDay";
export { DayIndicators } from "./DayIndicators";
export type { DayIndicatorsProps } from "./DayIndicators";
export type { CalendarGridProps } from "./CalendarGrid";
export type { CalendarHeaderProps } from "./CalendarHeader";
export type { CalendarDayProps } from "./CalendarDay";

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { resetAppStore } from "@/test/test-utils";
describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
});
it("returns null when phone and username are missing", () => {
const { container } = render(
<ContactLinks phone={null} username={null} />
);
expect(container.firstChild).toBeNull();
});
it("renders phone only with label and tel: link", () => {
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
});
it("displays phone formatted for Russian numbers", () => {
render(<ContactLinks phone="79146522209" username={null} />);
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
});
it("renders username only with label and t.me link", () => {
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
});
it("renders both phone and username with labels", () => {
render(
<ContactLinks
phone="+79001112233"
username="bob"
showLabels
/>
);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
expect(screen.getByText(/@bob/)).toBeInTheDocument();
});
it("strips leading @ from username and displays with @", () => {
render(<ContactLinks phone={null} username="@alice" />);
const link = document.querySelector('a[href*="t.me/alice"]');
expect(link).toBeInTheDocument();
expect(link?.textContent).toContain("@alice");
});
});

View File

@@ -0,0 +1,145 @@
/**
* Contact links (phone, Telegram) for duty cards and day detail.
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
export interface ContactLinksProps {
phone?: string | null;
username?: string | null;
layout?: "inline" | "block";
showLabels?: boolean;
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
contextLabel?: string;
className?: string;
}
const linkClass =
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
/**
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
*/
export function ContactLinks({
phone,
username,
layout = "inline",
showLabels = true,
contextLabel,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
if (!hasPhone && !hasUsername) return null;
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
const ariaTelegram = contextLabel
? t("contact.aria_telegram", { name: contextLabel })
: t("contact.telegram");
if (layout === "block") {
return (
<div className={cn("flex flex-col gap-2", className)}>
{hasPhone && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
</Button>
)}
{hasUsername && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
</a>
</Button>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
if (hasPhone) {
const displayPhone = formatPhoneDisplay(phone!);
parts.push(
showLabels ? (
<span key="phone">
{t("contact.phone")}:{" "}
<a
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
</span>
) : (
<a
key="phone"
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
)
);
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const link = (
<a
key="tg"
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
>
@{cleanUsername}
</a>
);
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
}
return (
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
{parts.map((p, i) => (
<span key={i} className="inline-flex items-center gap-x-1">
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
{p}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Contact links component.
*/
export { ContactLinks } from "./ContactLinks";
export type { ContactLinksProps } from "./ContactLinks";

View File

@@ -0,0 +1,84 @@
/**
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CurrentDutyView } from "./CurrentDutyView";
import { resetAppStore } from "@/test/test-utils";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
}));
vi.mock("@/lib/api", () => ({
fetchDuties: vi.fn().mockResolvedValue([]),
AccessDeniedError: class AccessDeniedError extends Error {
serverDetail?: string;
constructor(m: string, d?: string) {
super(m);
this.serverDetail = d;
}
},
}));
describe("CurrentDutyView", () => {
beforeEach(() => {
resetAppStore();
vi.clearAllMocks();
});
it("shows loading then no-duty message when no active duty", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
});
it("back button calls onBack when clicked", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
fireEvent.click(buttons[buttons.length - 1]);
expect(onBack).toHaveBeenCalled();
});
it("shows Close button when openedFromPin is true", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
});
it("shows contact info not set when duty has no phone or username", async () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
const dutyNoContacts = {
id: 1,
user_id: 1,
start_at: start.toISOString(),
end_at: end.toISOString(),
event_type: "duty" as const,
full_name: "Test User",
phone: null,
username: null,
};
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
});

View File

@@ -0,0 +1,323 @@
/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
* and contact links. Integrates with Telegram BackButton.
* Ported from webapp/js/currentDuty.js.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { fetchDuties, AccessDeniedError } from "@/lib/api";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
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 { 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]);
// Signal Telegram to hide loading when this view is ready (or error).
useEffect(() => {
if (state !== "loading") {
callMiniAppReadyOnce();
}
}, [state]);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
if (!duty) return;
const interval = setInterval(() => {
setRemaining(getRemainingTime(duty.end_at));
}, 1000);
return () => clearInterval(interval);
}, [duty]);
// Telegram BackButton: show on mount, hide on unmount, handle click.
useEffect(() => {
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onBack);
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [onBack]);
const handleBack = () => {
onBack();
};
const handleClose = () => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
} else {
onBack();
}
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
const primaryButtonAriaLabel = openedFromPin
? t("current_duty.close")
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<span
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
<p className="text-muted-foreground m-0">{t("loading")}</p>
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
</div>
);
}
if (state === "error") {
const handleRetry = () => {
setState("loading");
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6">
<p className="text-error">{errorMessage}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="default"
onClick={handleRetry}
aria-label={t("error.retry")}
>
{t("error.retry")}
</Button>
<Button
variant="outline"
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (!duty) {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
<CardHeader>
<CardTitle>{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<span
className="flex items-center justify-center text-muted-foreground"
aria-hidden
>
<Calendar className="size-12" strokeWidth={1.5} />
</span>
<p className="text-center text-muted-foreground">
{t("current_duty.no_duty")}
</p>
</CardContent>
<CardFooter>
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
const endTime = formatHHMM(duty.end_at);
const shiftStr = `${startDDMM} ${startTime}${endDDMM} ${endTime}`;
const rem = remaining ?? getRemainingTime(duty.end_at);
const remainingStr = t("current_duty.remaining", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
const endsAtStr = t("current_duty.ends_at", { time: endTime });
const displayTz =
typeof window !== "undefined" &&
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
const shiftLabel = displayTz
? t("current_duty.shift_tz", { tz: displayTz })
: t("current_duty.shift_local");
const hasContacts =
Boolean(duty.phone && String(duty.phone).trim()) ||
Boolean(duty.username && String(duty.username).trim());
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
role="article"
aria-labelledby="current-duty-title"
>
<CardHeader>
<CardTitle
id="current-duty-title"
className="flex items-center gap-2"
>
<span
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{t("current_duty.title")}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="font-medium text-foreground" id="current-duty-name">
{duty.full_name}
</p>
<p className="text-sm text-muted-foreground">
{shiftLabel} {shiftStr}
</p>
<div
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
aria-live="polite"
aria-atomic="true"
>
{remainingStr}
</div>
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
{hasContacts ? (
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
/>
) : (
<p className="text-sm text-muted-foreground">
{t("current_duty.contact_info_not_set")}
</p>
)}
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentDutyView } from "./CurrentDutyView";
export type { CurrentDutyViewProps } from "./CurrentDutyView";

View File

@@ -0,0 +1,77 @@
/**
* Unit tests for DayDetailContent: sorts duties by start_at, includes contact info.
* 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("includes contact info (phone, username) for duty entries when present", () => {
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:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,255 @@
/**
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
*/
"use client";
import * as React from "react";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useTranslation } from "@/i18n/use-translation";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
import { DayDetailContent } from "./DayDetailContent";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
/** Empty state for day detail: date and "no duties or events" message. */
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
const { t } = useTranslation();
const todayKey = localDateString(new Date());
const ddmm = dateKeyToDDMM(dateKey);
const title =
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
return (
<div className="flex flex-col gap-4">
<h2
id="day-detail-title"
className="text-[1.1rem] font-semibold leading-tight m-0"
>
{title}
</h2>
<p className="text-sm text-muted-foreground m-0">
{t("day_detail.no_events")}
</p>
</div>
);
}
export interface DayDetailHandle {
/** Open the panel for the given day with popover anchored at the given rect. */
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
}
export interface DayDetailProps {
/** All duties for the visible range (will be filtered by selectedDay). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when the panel should close. */
onClose: () => void;
className?: string;
}
/**
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
*/
function VirtualAnchor({
rect,
className,
}: {
rect: DOMRect;
className?: string;
}) {
return (
<div
className={cn("pointer-events-none fixed z-0", className)}
style={{
left: rect.left,
top: rect.bottom,
width: rect.width,
height: 1,
}}
aria-hidden
/>
);
}
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
const isDesktop = useIsDesktop();
const { t } = useTranslation();
const selectedDay = useAppStore((s) => s.selectedDay);
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
const [exiting, setExiting] = React.useState(false);
const open = selectedDay !== null;
React.useImperativeHandle(
ref,
() => ({
openWithRect(dateKey: string, rect: DOMRect) {
setSelectedDay(dateKey);
setAnchorRect(rect);
},
}),
[setSelectedDay]
);
const handleClose = React.useCallback(() => {
setSelectedDay(null);
setAnchorRect(null);
setExiting(false);
onClose();
}, [setSelectedDay, onClose]);
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
const requestClose = React.useCallback(() => {
setExiting(true);
}, []);
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
React.useEffect(() => {
if (!exiting) return;
const fallback = window.setTimeout(() => {
handleClose();
}, 320);
return () => window.clearTimeout(fallback);
}, [exiting, handleClose]);
const dutiesByDateMap = React.useMemo(
() => dutiesByDate(duties),
[duties]
);
const eventsByDateMap = React.useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
// Close popover/sheet on window resize so anchor position does not become stale.
React.useEffect(() => {
if (!open) return;
const onResize = () => handleClose();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [open, handleClose]);
const content =
selectedDay &&
(hasContent ? (
<DayDetailContent
dateKey={selectedDay}
duties={dayDuties}
eventSummaries={dayEvents}
/>
) : (
<DayDetailEmpty dateKey={selectedDay} />
));
if (!open || !selectedDay) return null;
const panelClassName =
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
const closeButton = (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={requestClose}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
);
const renderSheet = (withHandle: boolean) => (
<Sheet
open={!exiting && open}
onOpenChange={(o) => !o && requestClose()}
>
<SheetContent
side="bottom"
className={cn(
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh]",
className
)}
showCloseButton={false}
onCloseAnimationEnd={handleClose}
>
<div className="relative px-4">
{closeButton}
{withHandle && (
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
)}
<SheetHeader className="p-0">
<SheetTitle id="day-detail-sheet-title" className="sr-only">
{selectedDay}
</SheetTitle>
</SheetHeader>
{content}
</div>
</SheetContent>
</Sheet>
);
if (isDesktop === true && anchorRect != null) {
return (
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
<PopoverAnchor asChild>
<VirtualAnchor rect={anchorRect} />
</PopoverAnchor>
<PopoverContent
side="bottom"
sideOffset={8}
align="center"
className={cn(panelClassName, "relative", className)}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={handleClose}
>
{closeButton}
{content}
</PopoverContent>
</Popover>
);
}
return renderSheet(true);
}
);

View File

@@ -0,0 +1,195 @@
/**
* 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 { ContactLinks } from "@/components/contact";
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-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-2.5 [&_li]:flex [&_li]:flex-col [&_li]:gap-1">
{dutyRows.map((r) => (
<li key={r.id}>
{r.timePrefix && (
<span className="text-muted-foreground">{r.timePrefix} </span>
)}
<span className="font-semibold">{r.fullName}</span>
<ContactLinks
phone={r.phone}
username={r.username}
layout="inline"
showLabels={true}
className="text-[0.85rem] mt-0.5"
/>
</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-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{uniqueUnavailable.map((name) => (
<li key={name}>{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-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{uniqueVacation.map((name) => (
<li key={name}>{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-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{summaries.map((s) => (
<li key={String(s)}>{String(s)}</li>
))}
</ul>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { DayDetail } from "./DayDetail";
export { DayDetailContent } from "./DayDetailContent";
export type { DayDetailContentProps } from "./DayDetailContent";
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";

View File

@@ -0,0 +1,80 @@
/**
* Single duty row: event type label, name, time range.
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
export interface DutyItemProps {
duty: DutyWithUser;
/** Override type label (e.g. "On duty now"). */
typeLabelOverride?: string;
/** Show "until HH:MM" instead of full range (for current duty). */
showUntilEnd?: boolean;
/** Extra class, e.g. for current duty highlight. */
isCurrent?: boolean;
className?: string;
}
const borderByType = {
duty: "border-l-duty",
unavailable: "border-l-unavailable",
vacation: "border-l-vacation",
} as const;
/**
* Renders type badge, name, and time. Timeline cards use event_type for border color.
*/
export function DutyItem({
duty,
typeLabelOverride,
showUntilEnd = false,
isCurrent = false,
className,
}: DutyItemProps) {
const { t } = useTranslation();
const typeLabel =
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
let timeOrRange: string;
if (showUntilEnd && duty.event_type === "duty") {
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
const startStr = formatDateKey(duty.start_at);
const endStr = formatDateKey(duty.end_at);
timeOrRange = startStr === endStr ? startStr : `${startStr} ${endStr}`;
} else {
timeOrRange = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
}
return (
<div
className={cn(
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
"border-l-[3px] shadow-sm",
"min-h-0",
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
className
)}
data-slot="duty-item"
>
<span className="text-xs text-muted col-span-1 row-start-1">
{typeLabel}
</span>
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
{timeOrRange}
</span>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/**
* 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", () => {
useAppStore.getState().setDuties([]);
render(<DutyList />);
expect(screen.getByText(/No duties this month/i)).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));
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));
render(<DutyList />);
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,256 @@
/**
* 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 { 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 } = useAppStore(
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties }))
);
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());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
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 (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} />
</div>
</div>
);
})
) : (
<div
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<TimelineDateCell
dateLabel={dateLabel}
isToday={isToday}
/>
<span className="min-w-0" aria-hidden />
<div className="min-w-0" />
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function TimelineDateCell({
dateLabel,
isToday,
}: {
dateLabel: string;
isToday: boolean;
}) {
const { t } = useTranslation();
return (
<span
className={cn(
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
)}
>
{isToday ? (
<>
<span className="duty-timeline-date-label text-today block leading-tight">
{t("duty.today")}
</span>
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
{dateLabel}
</span>
</>
) : (
dateLabel
)}
</span>
);
}

View File

@@ -0,0 +1,188 @@
/**
* 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 {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import type { DutyWithUser } from "@/types";
import { Phone, ArrowLeft } from "lucide-react";
export interface DutyTimelineCardProps {
duty: DutyWithUser;
isCurrent: boolean;
}
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 }: DutyTimelineCardProps) {
const { t } = useTranslation();
const [flipped, setFlipped] = 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.start_at, duty.end_at]
);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
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>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<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={() => {
setFlipped(true);
setTimeout(() => backBtnRef.current?.focus(), 310);
}}
>
<Phone className="size-[18px]" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{t("contact.show")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</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={() => {
setFlipped(false);
setTimeout(() => frontBtnRef.current?.focus(), 310);
}}
>
<ArrowLeft className="size-[18px]" aria-hidden />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Duty list and timeline components.
*/
export { DutyList } from "./DutyList";
export { DutyTimelineCard } from "./DutyTimelineCard";
export { DutyItem } from "./DutyItem";
export type { DutyListProps } from "./DutyList";
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
export type { DutyItemProps } from "./DutyItem";

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import {
init,
mountMiniAppSync,
mountThemeParamsSync,
bindThemeParamsCssVars,
} from "@telegram-apps/sdk-react";
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
/**
* Wraps the app with Telegram Mini App SDK initialization.
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
* and mounts the mini app. Does not call ready() here — the app calls
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
* has finished loading, so Telegram keeps its native loading animation until then.
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
* useTelegramTheme() in the app handles ongoing theme changes.
*/
export function TelegramProvider({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
const cleanup = init({ acceptCustomStyles: true });
if (mountThemeParamsSync.isAvailable()) {
mountThemeParamsSync();
}
if (bindThemeParamsCssVars.isAvailable()) {
bindThemeParamsCssVars();
}
fixSurfaceContrast();
void document.documentElement.offsetHeight;
if (mountMiniAppSync.isAvailable()) {
mountMiniAppSync();
}
return cleanup;
}, []);
return <>{children}</>;
}

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
/**
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorState } from "./ErrorState";
import { resetAppStore } from "@/test/test-utils";
describe("ErrorState", () => {
beforeEach(() => resetAppStore());
it("renders error message", () => {
render(<ErrorState message="Network error" onRetry={undefined} />);
expect(screen.getByText("Network error")).toBeInTheDocument();
});
it("renders Retry button when onRetry provided", () => {
const onRetry = vi.fn();
render(<ErrorState message="Fail" onRetry={onRetry} />);
const retry = screen.getByRole("button", { name: /retry|повторить/i });
expect(retry).toBeInTheDocument();
retry.click();
expect(onRetry).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
/**
* Error state: warning icon, message, and optional Retry button.
* Ported from webapp/js/ui.js showError and states.css .error.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ErrorStateProps {
/** Error message to display. If not provided, uses generic i18n message. */
message?: string | null;
/** Optional retry callback; when provided, a Retry button is shown. */
onRetry?: (() => void) | null;
/** Optional class for the container. */
className?: string;
}
/** Warning triangle icon 24×24 for error state. */
function ErrorIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("shrink-0 text-error", className)}
aria-hidden
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
/**
* Displays an error message with optional Retry button.
*/
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
const { t } = useTranslation();
const displayMessage =
message && String(message).trim() ? message : t("error_generic");
return (
<div
className={cn(
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
className
)}
role="alert"
>
<ErrorIcon />
<p className="m-0 text-sm font-medium">{displayMessage}</p>
{typeof onRetry === "function" && (
<Button
type="button"
variant="default"
size="sm"
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
onClick={onRetry}
>
{t("error.retry")}
</Button>
)}
</div>
);
}

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

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

View File

@@ -0,0 +1,7 @@
/**
* State components: loading, error, access denied.
*/
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { AccessDenied } from "./AccessDenied";

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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-500",
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,
}

View 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-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View 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 }

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

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

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

View File

@@ -0,0 +1,182 @@
/**
* 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 store.currentMonth when enabled.
* 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.
*
* The load callback is stabilized (empty dependency array) and reads latest
* options from a ref and currentMonth/lang from Zustand getState(), so the
* effect that calls load only re-runs when enabled, currentMonth, lang, or
* initDataRaw actually change.
*/
export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
const { initDataRaw, enabled } = options;
const currentMonth = useAppStore((s) => s.currentMonth);
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) return;
const initData = initDataRawOpt ?? "";
if (!initData && typeof window !== "undefined") {
const h = window.location.hostname;
if (h !== "localhost" && h !== "127.0.0.1" && h !== "") return;
}
const store = useAppStore.getState();
const currentMonthNow = store.currentMonth;
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,
});
const first = firstDayOfMonth(currentMonthNow);
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(currentMonthNow);
const firstKey = localDateString(first);
const lastKey = localDateString(last);
const dutiesInMonth = duties.filter((d) =>
dutyOverlapsLocalRange(d, firstKey, lastKey)
);
useAppStore.getState().batchUpdate({
duties: dutiesInMonth,
calendarEvents: events,
loading: false,
error: null,
});
} catch (e) {
if ((e as Error).name === "AbortError") return;
if (e instanceof AccessDeniedError) {
logger.warn("Access denied in loadMonth", e.serverDetail);
useAppStore.getState().batchUpdate({
accessDenied: true,
accessDeniedDetail: e.serverDetail ?? null,
loading: false,
});
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,
});
}
};
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, 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 };
}

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

View File

@@ -0,0 +1,67 @@
/**
* Touch swipe detection for horizontal month navigation.
* Replaces swipe logic from webapp/js/main.js (threshold 50px).
*/
"use client";
import { useEffect, useRef } from "react";
export interface UseSwipeOptions {
/** Minimum horizontal distance (px) to count as swipe. Default 50. */
threshold?: number;
/** When true, swipe handlers are not attached. */
disabled?: boolean;
}
/**
* Attaches touchstart/touchend to the element ref and invokes onSwipeLeft or onSwipeRight
* when a horizontal swipe exceeds the threshold. Vertical swipes are ignored.
*/
export function useSwipe(
elementRef: React.RefObject<HTMLElement | null>,
onSwipeLeft: () => void,
onSwipeRight: () => void,
options: UseSwipeOptions = {}
): void {
const { threshold = 50, disabled = false } = options;
const startX = useRef(0);
const startY = useRef(0);
const onSwipeLeftRef = useRef(onSwipeLeft);
const onSwipeRightRef = useRef(onSwipeRight);
onSwipeLeftRef.current = onSwipeLeft;
onSwipeRightRef.current = onSwipeRight;
useEffect(() => {
const el = elementRef.current;
if (!el || disabled) return;
const handleStart = (e: TouchEvent) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
startX.current = t.clientX;
startY.current = t.clientY;
};
const handleEnd = (e: TouchEvent) => {
if (e.changedTouches.length === 0) return;
const t = e.changedTouches[0];
const deltaX = t.clientX - startX.current;
const deltaY = t.clientY - startY.current;
if (Math.abs(deltaX) <= threshold) return;
if (Math.abs(deltaY) > Math.abs(deltaX)) return;
if (deltaX > threshold) {
onSwipeRightRef.current();
} else if (deltaX < -threshold) {
onSwipeLeftRef.current();
}
};
el.addEventListener("touchstart", handleStart, { passive: true });
el.addEventListener("touchend", handleEnd, { passive: true });
return () => {
el.removeEventListener("touchstart", handleStart);
el.removeEventListener("touchend", handleEnd);
};
}, [elementRef, disabled, threshold]);
}

View File

@@ -0,0 +1,50 @@
/**
* Unit tests for use-telegram-auth: isLocalhost.
* Ported from webapp/js/auth.test.js. getInitData is handled by SDK in the hook.
*/
import { describe, it, expect, afterEach } from "vitest";
import { isLocalhost } from "./use-telegram-auth";
describe("isLocalhost", () => {
const origLocation = window.location;
afterEach(() => {
Object.defineProperty(window, "location", {
value: origLocation,
writable: true,
});
});
it("returns true for localhost", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "localhost" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns true for 127.0.0.1", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "127.0.0.1" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns true for empty hostname", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "" },
writable: true,
});
expect(isLocalhost()).toBe(true);
});
it("returns false for other hostnames", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, hostname: "example.com" },
writable: true,
});
expect(isLocalhost()).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import {
retrieveRawInitData,
retrieveLaunchParams,
} from "@telegram-apps/sdk-react";
import { getStartParamFromUrl } from "@/lib/launch-params";
/**
* Whether the app is running on localhost (dev without Telegram).
*/
export function isLocalhost(): boolean {
if (typeof window === "undefined") return false;
const h = window.location.hostname;
return h === "localhost" || h === "127.0.0.1" || h === "";
}
/**
* Telegram auth and launch context for API and deep links.
* Replaces webapp/js/auth.js getInitData, isLocalhost, and startParam detection.
*
* Uses imperative retrieveRawInitData/retrieveLaunchParams in useEffect so that
* non-Telegram environments (e.g. browser) do not throw during render.
* start_param is also read from URL (search/hash) as fallback when SDK is delayed.
*
* - initDataRaw: string for X-Telegram-Init-Data header (undefined when not in TWA)
* - startParam: deep link param (e.g. "duty" for current duty view)
* - isLocalhost: true when hostname is localhost/127.0.0.1 for dev without Telegram
*/
export function useTelegramAuth(): {
initDataRaw: string | undefined;
startParam: string | undefined;
isLocalhost: boolean;
} {
const urlStartParam = useMemo(() => getStartParamFromUrl(), []);
const [initDataRaw, setInitDataRaw] = useState<string | undefined>(undefined);
const [startParam, setStartParam] = useState<string | undefined>(urlStartParam);
const localhost = useMemo(() => isLocalhost(), []);
useEffect(() => {
try {
const raw = retrieveRawInitData();
setInitDataRaw(raw ?? undefined);
const lp = retrieveLaunchParams();
const param =
typeof lp.start_param === "string" ? lp.start_param : urlStartParam;
setStartParam(param ?? urlStartParam ?? undefined);
} catch {
setInitDataRaw(undefined);
setStartParam(urlStartParam ?? undefined);
}
}, [urlStartParam]);
return {
initDataRaw,
startParam,
isLocalhost: localhost,
};
}

View File

@@ -0,0 +1,140 @@
/**
* Unit tests for useTelegramTheme, getFallbackScheme, and applyTheme.
* Ported from webapp/js/theme.test.js (getTheme, applyTheme).
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook } from "@testing-library/react";
import {
useTelegramTheme,
getFallbackScheme,
applyTheme,
} from "./use-telegram-theme";
vi.mock("@telegram-apps/sdk-react", () => ({
useSignal: vi.fn(() => undefined),
isThemeParamsDark: vi.fn(),
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
}));
describe("getFallbackScheme", () => {
const originalMatchMedia = window.matchMedia;
const originalGetComputedStyle = window.getComputedStyle;
afterEach(() => {
window.matchMedia = originalMatchMedia;
window.getComputedStyle = originalGetComputedStyle;
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("returns dark when prefers-color-scheme is dark", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
expect(getFallbackScheme()).toBe("dark");
});
it("returns light when prefers-color-scheme is light", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: light)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
expect(getFallbackScheme()).toBe("light");
});
it("uses --tg-color-scheme when set on document", () => {
window.getComputedStyle = vi.fn(() =>
Object.assign(
{},
{
getPropertyValue: (prop: string) =>
prop === "--tg-color-scheme" ? " light " : "",
}
)
) as unknown as typeof window.getComputedStyle;
expect(getFallbackScheme()).toBe("light");
});
it("uses --tg-color-scheme dark when set", () => {
window.getComputedStyle = vi.fn(() =>
Object.assign(
{},
{
getPropertyValue: (prop: string) =>
prop === "--tg-color-scheme" ? "dark" : "",
}
)
) as unknown as typeof window.getComputedStyle;
expect(getFallbackScheme()).toBe("dark");
});
});
describe("applyTheme", () => {
afterEach(() => {
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("sets data-theme to given scheme", () => {
applyTheme("light");
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
applyTheme("dark");
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("resolves scheme via getFallbackScheme when no argument", () => {
window.matchMedia = vi.fn((query: string) => ({
matches: query === "(prefers-color-scheme: dark)",
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
onchange: null,
})) as unknown as typeof window.matchMedia;
applyTheme();
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
});
describe("useTelegramTheme", () => {
const originalMatchMedia = window.matchMedia;
const originalGetComputedStyle = window.getComputedStyle;
afterEach(() => {
window.matchMedia = originalMatchMedia;
window.getComputedStyle = originalGetComputedStyle;
document.documentElement.removeAttribute("data-theme");
vi.clearAllMocks();
});
it("sets data-theme to dark when useSignal returns true", async () => {
const { useSignal } = await import("@telegram-apps/sdk-react");
vi.mocked(useSignal).mockReturnValue(true);
renderHook(() => useTelegramTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("sets data-theme to light when useSignal returns false", async () => {
const { useSignal } = await import("@telegram-apps/sdk-react");
vi.mocked(useSignal).mockReturnValue(false);
renderHook(() => useTelegramTheme());
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
});

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
import {
useSignal,
isThemeParamsDark,
setMiniAppBackgroundColor,
setMiniAppHeaderColor,
} from "@telegram-apps/sdk-react";
/**
* Resolves color scheme when Telegram theme is not available (SSR or non-TWA).
* Uses --tg-color-scheme (if set by Telegram) then prefers-color-scheme.
*/
export function getFallbackScheme(): "dark" | "light" {
if (typeof window === "undefined") return "dark";
try {
const cssScheme = getComputedStyle(document.documentElement)
.getPropertyValue("--tg-color-scheme")
.trim();
if (cssScheme === "light" || cssScheme === "dark") return cssScheme;
} catch {
// ignore
}
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
return "light";
}
/**
* Ensure --surface differs from --bg so cards/cells are visible.
* iOS OLED sends secondary_bg === bg (#000000) while section_bg differs;
* PC desktop sends section_bg === bg (#17212b) while secondary_bg differs.
* When the CSS-resolved --surface equals --bg, override with whichever
* Telegram color provides contrast, or a synthesized lighter fallback.
*/
export function fixSurfaceContrast(): void {
const root = document.documentElement;
const cs = getComputedStyle(root);
const bg = cs.getPropertyValue("--bg").trim();
const surface = cs.getPropertyValue("--surface").trim();
if (!bg || !surface || bg !== surface) return;
const sectionBg = cs.getPropertyValue("--tg-theme-section-bg-color").trim();
if (sectionBg && sectionBg !== bg) {
root.style.setProperty("--surface", sectionBg);
return;
}
const secondaryBg = cs.getPropertyValue("--tg-theme-secondary-bg-color").trim();
if (secondaryBg && secondaryBg !== bg) {
root.style.setProperty("--surface", secondaryBg);
return;
}
root.style.setProperty("--surface", `color-mix(in srgb, ${bg}, white 8%)`);
}
/**
* Applies theme: sets data-theme, forces reflow, fixes surface contrast,
* then Mini App background/header.
* Shared by TelegramProvider (initial + delayed) and useTelegramTheme.
* @param scheme - If provided, use it; otherwise resolve via getFallbackScheme().
*/
export function applyTheme(scheme?: "dark" | "light"): void {
const resolved = scheme ?? getFallbackScheme();
document.documentElement.setAttribute("data-theme", resolved);
void document.documentElement.offsetHeight; // force reflow so WebView repaints
fixSurfaceContrast();
if (setMiniAppBackgroundColor.isAvailable()) {
setMiniAppBackgroundColor("bg_color");
}
if (setMiniAppHeaderColor.isAvailable()) {
setMiniAppHeaderColor("bg_color");
}
}
/**
* Maps Telegram theme params to data-theme and Mini App background/header.
* Subscribes to theme changes via SDK signals.
* Ported from webapp/js/theme.js applyTheme / initTheme.
*/
export function useTelegramTheme(): "dark" | "light" {
const signalDark = useSignal(isThemeParamsDark);
const isDark =
typeof signalDark === "boolean" ? signalDark : getFallbackScheme() === "dark";
useEffect(() => {
applyTheme(isDark ? "dark" : "light");
}, [isDark]);
return isDark ? "dark" : "light";
}

View File

@@ -1,56 +1,61 @@
/** /**
* Unit tests for i18n: getLang (window.__DT_LANG), normalizeLang, t (fallback, params), monthName. * Unit tests for i18n: getLang (window.__DT_LANG), normalizeLang, translate, monthName.
* Ported from webapp/js/i18n.test.js.
*/ */
import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
import { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js"; getLang,
normalizeLang,
translate,
monthName,
} from "./messages";
describe("getLang", () => { describe("getLang", () => {
const orig__DT_LANG = globalThis.window?.__DT_LANG; const orig__DT_LANG = (globalThis as unknown as { window?: { __DT_LANG?: string } }).window?.__DT_LANG;
afterEach(() => { afterEach(() => {
if (typeof globalThis.window !== "undefined") { if (typeof globalThis.window !== "undefined") {
if (orig__DT_LANG !== undefined) { if (orig__DT_LANG !== undefined) {
globalThis.window.__DT_LANG = orig__DT_LANG; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = orig__DT_LANG;
} else { } else {
delete globalThis.window.__DT_LANG; delete (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG;
} }
} }
}); });
it("returns ru when window.__DT_LANG is ru", () => { it("returns ru when window.__DT_LANG is ru", () => {
globalThis.window.__DT_LANG = "ru"; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
expect(getLang()).toBe("ru"); expect(getLang()).toBe("ru");
}); });
it("returns en when window.__DT_LANG is en", () => { it("returns en when window.__DT_LANG is en", () => {
globalThis.window.__DT_LANG = "en"; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "en";
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("returns en when window.__DT_LANG is missing", () => { it("returns en when window.__DT_LANG is missing", () => {
delete globalThis.window.__DT_LANG; delete (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG;
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("returns en when window.__DT_LANG is invalid (unknown code)", () => { it("returns en when window.__DT_LANG is invalid (unknown code)", () => {
globalThis.window.__DT_LANG = "uk"; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "uk";
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => { it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => {
globalThis.window.__DT_LANG = "ru-RU"; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru-RU";
expect(getLang()).toBe("ru"); expect(getLang()).toBe("ru");
}); });
it("returns en when window.__DT_LANG is empty string", () => { it("returns en when window.__DT_LANG is empty string", () => {
globalThis.window.__DT_LANG = ""; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "";
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
it("returns en when window.__DT_LANG is null", () => { it("returns en when window.__DT_LANG is null", () => {
globalThis.window.__DT_LANG = null; (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = null as unknown as string;
expect(getLang()).toBe("en"); expect(getLang()).toBe("en");
}); });
}); });
@@ -73,29 +78,29 @@ describe("normalizeLang", () => {
}); });
}); });
describe("t", () => { describe("translate", () => {
it("returns translation for existing key", () => { it("returns translation for existing key", () => {
expect(t("en", "app.title")).toBe("Duty Calendar"); expect(translate("en", "app.title")).toBe("Duty Calendar");
expect(t("ru", "app.title")).toBe("Календарь дежурств"); expect(translate("ru", "app.title")).toBe("Календарь дежурств");
}); });
it("falls back to en when key missing in lang", () => { it("falls back to en when key missing in lang", () => {
expect(t("ru", "app.title")).toBe("Календарь дежурств"); expect(translate("ru", "app.title")).toBe("Календарь дежурств");
expect(t("en", "loading")).toBe("Loading…"); expect(translate("en", "loading")).toBe("Loading…");
}); });
it("returns key when key missing in both", () => { it("returns key when key missing in both", () => {
expect(t("en", "missing.key")).toBe("missing.key"); expect(translate("en", "missing.key")).toBe("missing.key");
expect(t("ru", "unknown")).toBe("unknown"); expect(translate("ru", "unknown")).toBe("unknown");
}); });
it("replaces params placeholder", () => { it("replaces params placeholder", () => {
expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00"); expect(translate("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30"); expect(translate("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
}); });
it("handles empty params", () => { it("handles empty params", () => {
expect(t("en", "loading", {})).toBe("Loading…"); expect(translate("en", "loading", {})).toBe("Loading…");
}); });
}); });

View File

@@ -1,9 +1,11 @@
/** /**
* Internationalization: language from backend config (window.__DT_LANG) and translations. * Internationalization: message dictionary (en/ru) and pure translation helpers.
* Ported from webapp/js/i18n.js. Language is read from window.__DT_LANG (backend config).
*/ */
/** @type {Record<string, Record<string, string>>} */ export type Lang = "ru" | "en";
export const MESSAGES = {
export const MESSAGES: Record<Lang, Record<string, string>> = {
en: { en: {
"app.title": "Duty Calendar", "app.title": "Duty Calendar",
loading: "Loading…", loading: "Loading…",
@@ -14,6 +16,8 @@ export const MESSAGES = {
"error.retry": "Retry", "error.retry": "Retry",
"nav.prev_month": "Previous month", "nav.prev_month": "Previous month",
"nav.next_month": "Next month", "nav.next_month": "Next month",
"nav.today": "Today",
"nav.refresh": "Refresh",
"weekdays.mon": "Mon", "weekdays.mon": "Mon",
"weekdays.tue": "Tue", "weekdays.tue": "Tue",
"weekdays.wed": "Wed", "weekdays.wed": "Wed",
@@ -43,6 +47,7 @@ export const MESSAGES = {
"event_type.other": "Other", "event_type.other": "Other",
"duty.now_on_duty": "On duty now", "duty.now_on_duty": "On duty now",
"duty.none_this_month": "No duties this month.", "duty.none_this_month": "No duties this month.",
"duty.none_this_month_hint": "Duties will appear after the schedule is loaded.",
"duty.today": "Today", "duty.today": "Today",
"duty.until": "until {time}", "duty.until": "until {time}",
"hint.from": "from", "hint.from": "from",
@@ -50,16 +55,31 @@ export const MESSAGES = {
"hint.duty_title": "Duty:", "hint.duty_title": "Duty:",
"hint.events": "Events:", "hint.events": "Events:",
"day_detail.close": "Close", "day_detail.close": "Close",
"day_detail.no_events": "No duties or events this day.",
"contact.label": "Contact", "contact.label": "Contact",
"contact.show": "Contacts", "contact.show": "Contacts",
"contact.back": "Back", "contact.back": "Back",
"contact.phone": "Phone", "contact.phone": "Phone",
"contact.telegram": "Telegram", "contact.telegram": "Telegram",
"contact.aria_call": "Call {name}",
"contact.aria_telegram": "Message {name} on Telegram",
"current_duty.title": "Current Duty", "current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now", "current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift", "current_duty.shift": "Shift",
"current_duty.shift_tz": "Shift ({tz}):",
"current_duty.shift_local": "Shift (your time):",
"current_duty.remaining": "Remaining: {hours}h {minutes}min", "current_duty.remaining": "Remaining: {hours}h {minutes}min",
"current_duty.back": "Back to calendar" "current_duty.ends_at": "Until end of shift at {time}",
"current_duty.back": "Back to calendar",
"current_duty.close": "Close",
"current_duty.contact_info_not_set": "Contact info not set",
"error_boundary.message": "Something went wrong.",
"error_boundary.description": "An unexpected error occurred. Try reloading the app.",
"error_boundary.reload": "Reload",
"not_found.title": "Page not found",
"not_found.description": "The page you are looking for does not exist.",
"not_found.open_calendar": "Open calendar",
"access_denied.hint": "Open the app again from Telegram.",
}, },
ru: { ru: {
"app.title": "Календарь дежурств", "app.title": "Календарь дежурств",
@@ -71,6 +91,8 @@ export const MESSAGES = {
"error.retry": "Повторить", "error.retry": "Повторить",
"nav.prev_month": "Предыдущий месяц", "nav.prev_month": "Предыдущий месяц",
"nav.next_month": "Следующий месяц", "nav.next_month": "Следующий месяц",
"nav.today": "Сегодня",
"nav.refresh": "Обновить",
"weekdays.mon": "Пн", "weekdays.mon": "Пн",
"weekdays.tue": "Вт", "weekdays.tue": "Вт",
"weekdays.wed": "Ср", "weekdays.wed": "Ср",
@@ -100,6 +122,7 @@ export const MESSAGES = {
"event_type.other": "Другое", "event_type.other": "Другое",
"duty.now_on_duty": "Сейчас дежурит", "duty.now_on_duty": "Сейчас дежурит",
"duty.none_this_month": "В этом месяце дежурств нет.", "duty.none_this_month": "В этом месяце дежурств нет.",
"duty.none_this_month_hint": "Дежурства появятся после загрузки расписания.",
"duty.today": "Сегодня", "duty.today": "Сегодня",
"duty.until": "до {time}", "duty.until": "до {time}",
"hint.from": "с", "hint.from": "с",
@@ -107,35 +130,63 @@ export const MESSAGES = {
"hint.duty_title": "Дежурство:", "hint.duty_title": "Дежурство:",
"hint.events": "События:", "hint.events": "События:",
"day_detail.close": "Закрыть", "day_detail.close": "Закрыть",
"day_detail.no_events": "В этот день нет дежурств и событий.",
"contact.label": "Контакт", "contact.label": "Контакт",
"contact.show": "Контакты", "contact.show": "Контакты",
"contact.back": "Назад", "contact.back": "Назад",
"contact.phone": "Телефон", "contact.phone": "Телефон",
"contact.telegram": "Telegram", "contact.telegram": "Telegram",
"contact.aria_call": "Позвонить {name}",
"contact.aria_telegram": "Написать {name} в Telegram",
"current_duty.title": "Сейчас дежурит", "current_duty.title": "Сейчас дежурит",
"current_duty.no_duty": "Сейчас никто не дежурит", "current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена", "current_duty.shift": "Смена",
"current_duty.shift_tz": "Смена ({tz}):",
"current_duty.shift_local": "Смена (ваше время):",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин", "current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.back": "Назад к календарю" "current_duty.ends_at": "До конца смены в {time}",
} "current_duty.back": "Назад к календарю",
"current_duty.close": "Закрыть",
"current_duty.contact_info_not_set": "Контактные данные не указаны",
"error_boundary.message": "Что-то пошло не так.",
"error_boundary.description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",
"error_boundary.reload": "Обновить",
"not_found.title": "Страница не найдена",
"not_found.description": "Запрашиваемая страница не существует.",
"not_found.open_calendar": "Открыть календарь",
"access_denied.hint": "Откройте приложение снова из Telegram.",
},
}; };
const MONTH_KEYS = [ const MONTH_KEYS = [
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun", "month.jan",
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec" "month.feb",
"month.mar",
"month.apr",
"month.may",
"month.jun",
"month.jul",
"month.aug",
"month.sep",
"month.oct",
"month.nov",
"month.dec",
]; ];
const WEEKDAY_KEYS = [ const WEEKDAY_KEYS = [
"weekdays.mon", "weekdays.tue", "weekdays.wed", "weekdays.thu", "weekdays.mon",
"weekdays.fri", "weekdays.sat", "weekdays.sun" "weekdays.tue",
"weekdays.wed",
"weekdays.thu",
"weekdays.fri",
"weekdays.sat",
"weekdays.sun",
]; ];
/** /**
* Normalize language code to 'ru' or 'en'. * Normalize language code to 'ru' or 'en'.
* @param {string} code - e.g. 'ru', 'en', 'uk'
* @returns {'ru'|'en'}
*/ */
export function normalizeLang(code) { export function normalizeLang(code: string | null | undefined): Lang {
if (!code || typeof code !== "string") return "en"; if (!code || typeof code !== "string") return "en";
const lower = code.toLowerCase(); const lower = code.toLowerCase();
if (lower.startsWith("ru")) return "ru"; if (lower.startsWith("ru")) return "ru";
@@ -145,71 +196,65 @@ export function normalizeLang(code) {
/** /**
* Get application language from backend config (window.__DT_LANG). * Get application language from backend config (window.__DT_LANG).
* Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid. * Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid.
* @returns {'ru'|'en'} * Only valid in browser; returns 'en' when window is undefined (SSR).
*/ */
export function getLang() { export function getLang(): Lang {
if (typeof window === "undefined") return "en";
const raw = const raw =
typeof window !== "undefined" && window.__DT_LANG != null (window as unknown as { __DT_LANG?: string }).__DT_LANG != null
? String(window.__DT_LANG) ? String((window as unknown as { __DT_LANG?: string }).__DT_LANG)
: ""; : "";
const lang = normalizeLang(raw); const lang = normalizeLang(raw);
return lang === "ru" ? "ru" : "en"; return lang === "ru" ? "ru" : "en";
} }
/** function tEventType(lang: Lang, key: string): string {
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}. const dict = MESSAGES[lang] ?? MESSAGES.en;
* @param {'ru'|'en'} lang
* @param {string} key - e.g. 'app.title', 'duty.until'
* @param {Record<string, string>} [params] - e.g. { time: '14:00' }
* @returns {string}
*/
/**
* Resolve event_type.* key with fallback: unknown types use event_type.duty or event_type.other.
* @param {'ru'|'en'} lang
* @param {string} key
* @returns {string}
*/
function tEventType(lang, key) {
const dict = MESSAGES[lang] || MESSAGES.en;
const enDict = MESSAGES.en; const enDict = MESSAGES.en;
let s = dict[key]; let s = dict[key];
if (s === undefined) s = enDict[key]; if (s === undefined) s = enDict[key];
if (s === undefined) { if (s === undefined) {
s = dict["event_type.other"] || enDict["event_type.other"] || enDict["event_type.duty"] || key; s =
dict["event_type.other"] ??
enDict["event_type.other"] ??
enDict["event_type.duty"] ??
key;
} }
return s; return s;
} }
export function t(lang, key, params = {}) { /**
const dict = MESSAGES[lang] || MESSAGES.en; * Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
*/
export function translate(
lang: Lang,
key: string,
params: Record<string, string> = {}
): string {
const dict = MESSAGES[lang] ?? MESSAGES.en;
let s = dict[key]; let s = dict[key];
if (s === undefined) s = MESSAGES.en[key]; if (s === undefined) s = MESSAGES.en[key];
if (s === undefined && key.startsWith("event_type.")) { if (s === undefined && key.startsWith("event_type.")) {
return tEventType(lang, key); return tEventType(lang, key);
} }
if (s === undefined) return key; if (s === undefined) return key;
Object.keys(params).forEach((k) => { for (const k of Object.keys(params)) {
s = s.replace(new RegExp("\\{" + k + "\\}", "g"), params[k]); s = s.split(`{${k}}`).join(params[k]);
}); }
return s; return s;
} }
/** /**
* Get month name by 0-based index. * Get month name by 0-based index (011).
* @param {'ru'|'en'} lang
* @param {number} month - 011
* @returns {string}
*/ */
export function monthName(lang, month) { export function monthName(lang: Lang, month: number): string {
const key = MONTH_KEYS[month]; const key = MONTH_KEYS[month];
return key ? t(lang, key) : ""; return key ? translate(lang, key) : "";
} }
/** /**
* Get weekday short labels (MonSun order) for given lang. * Get weekday short labels (MonSun order) for given lang.
* @param {'ru'|'en'} lang
* @returns {string[]}
*/ */
export function weekdayLabels(lang) { export function weekdayLabels(lang: Lang): string[] {
return WEEKDAY_KEYS.map((k) => t(lang, k)); return WEEKDAY_KEYS.map((k) => translate(lang, k));
} }

View File

@@ -0,0 +1,24 @@
/**
* React hook for translations. Uses app store lang and pure translate helpers.
*/
"use client";
import { useAppStore } from "@/store/app-store";
import {
translate,
monthName as monthNameFn,
weekdayLabels as weekdayLabelsFn,
type Lang,
} from "./messages";
export function useTranslation() {
const lang = useAppStore((s) => s.lang) as Lang;
return {
t: (key: string, params?: Record<string, string>) =>
translate(lang, key, params ?? {}),
lang,
monthName: (month: number) => monthNameFn(lang, month),
weekdayLabels: () => weekdayLabelsFn(lang),
};
}

View File

@@ -0,0 +1,176 @@
/**
* Unit tests for api: fetchDuties (403 handling, AbortError), fetchCalendarEvents.
* Ported from webapp/js/api.test.js. buildFetchOptions is tested indirectly via fetch mock.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
describe("fetchDuties", () => {
const originalFetch = globalThis.fetch;
const validDuty = {
id: 1,
user_id: 10,
start_at: "2025-02-01T09:00:00Z",
end_at: "2025-02-01T18:00:00Z",
full_name: "Test User",
event_type: "duty" as const,
phone: null as string | null,
username: "@test",
};
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve([validDuty]),
});
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns JSON on 200", async () => {
const result = await fetchDuties(
"2025-02-01",
"2025-02-28",
"test-init-data",
"ru"
);
expect(result).toEqual([validDuty]);
});
it("sets X-Telegram-Init-Data and Accept-Language headers", async () => {
let capturedOpts: RequestInit | null = null;
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((_url: string, opts?: RequestInit) => {
capturedOpts = opts ?? null;
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
});
await fetchDuties("2025-02-01", "2025-02-28", "init-data-string", "en");
const headers = capturedOpts?.headers as Record<string, string>;
expect(headers["X-Telegram-Init-Data"]).toBe("init-data-string");
expect(headers["Accept-Language"]).toBe("en");
});
it("omits X-Telegram-Init-Data when initData empty", async () => {
let capturedOpts: RequestInit | null = null;
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((_url: string, opts?: RequestInit) => {
capturedOpts = opts ?? null;
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
});
await fetchDuties("2025-02-01", "2025-02-28", "", "ru");
const headers = capturedOpts?.headers as Record<string, string>;
expect(headers["X-Telegram-Init-Data"]).toBeUndefined();
});
it("throws AccessDeniedError on 403 with server detail from body", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Custom access denied" }),
} as Response);
await expect(
fetchDuties("2025-02-01", "2025-02-28", "test", "ru")
).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Custom access denied",
});
await expect(
fetchDuties("2025-02-01", "2025-02-28", "test", "ru")
).rejects.toThrow(AccessDeniedError);
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.reject(abortError));
await expect(
fetchDuties("2025-02-01", "2025-02-28", "test", "ru", aborter.signal)
).rejects.toMatchObject({ name: "AbortError" });
});
});
describe("fetchCalendarEvents", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn());
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
status: 200,
json: () =>
Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
} as Response);
});
afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
it("returns JSON array on 200", async () => {
const result = await fetchCalendarEvents(
"2025-02-01",
"2025-02-28",
"init-data",
"ru"
);
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
});
it("returns empty array on non-OK response", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 500,
} as Response);
const result = await fetchCalendarEvents(
"2025-02-01",
"2025-02-28",
"init-data",
"ru"
);
expect(result).toEqual([]);
});
it("throws AccessDeniedError on 403", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Access denied" }),
} as Response);
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", "init-data", "ru")
).rejects.toThrow(AccessDeniedError);
await expect(
fetchCalendarEvents("2025-02-01", "2025-02-28", "init-data", "ru")
).rejects.toMatchObject({
message: "ACCESS_DENIED",
serverDetail: "Access denied",
});
});
it("rethrows AbortError when request is aborted", async () => {
const aborter = new AbortController();
const abortError = new DOMException("aborted", "AbortError");
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.reject(abortError));
await expect(
fetchCalendarEvents(
"2025-02-01",
"2025-02-28",
"init-data",
"ru",
aborter.signal
)
).rejects.toMatchObject({ name: "AbortError" });
});
});

209
webapp-next/src/lib/api.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* API layer: fetch duties and calendar events with auth header and error handling.
* Ported from webapp/js/api.js. Uses fetch with X-Telegram-Init-Data and Accept-Language.
* No Next.js fetch caching — client-side SPA with static export.
*/
import { FETCH_TIMEOUT_MS } from "./constants";
import { logger } from "./logger";
import type { DutyWithUser, CalendarEvent } from "@/types";
import { translate } from "@/i18n/messages";
type ApiLang = "ru" | "en";
/** Minimal runtime check for a single duty item (required fields). */
function isDutyWithUser(x: unknown): x is DutyWithUser {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
return (
typeof o.id === "number" &&
typeof o.user_id === "number" &&
typeof o.start_at === "string" &&
typeof o.end_at === "string" &&
typeof o.full_name === "string" &&
(o.event_type === "duty" || o.event_type === "unavailable" || o.event_type === "vacation") &&
(o.phone === null || typeof o.phone === "string") &&
(o.username === null || typeof o.username === "string")
);
}
/** Minimal runtime check for a single calendar event. */
function isCalendarEvent(x: unknown): x is CalendarEvent {
if (!x || typeof x !== "object") return false;
const o = x as Record<string, unknown>;
return typeof o.date === "string" && typeof o.summary === "string";
}
function validateDuties(data: unknown, acceptLang: ApiLang): DutyWithUser[] {
if (!Array.isArray(data)) {
logger.warn("API response is not an array (duties)", typeof data);
throw new Error(translate(acceptLang, "error_load_failed"));
}
for (let i = 0; i < data.length; i++) {
if (!isDutyWithUser(data[i])) {
logger.warn("API duties item invalid at index", i, data[i]);
throw new Error(translate(acceptLang, "error_load_failed"));
}
}
return data as DutyWithUser[];
}
function validateCalendarEvents(data: unknown): CalendarEvent[] {
if (!Array.isArray(data)) {
logger.warn("API response is not an array (calendar-events)", typeof data);
return [];
}
const out: CalendarEvent[] = [];
for (let i = 0; i < data.length; i++) {
if (isCalendarEvent(data[i])) {
out.push(data[i]);
} else {
logger.warn("API calendar-events item invalid at index", i, data[i]);
}
}
return out;
}
const API_ACCESS_DENIED = "ACCESS_DENIED";
/** Error thrown on 403 with server detail attached. */
export class AccessDeniedError extends Error {
constructor(
message: string = API_ACCESS_DENIED,
public serverDetail?: string
) {
super(message);
this.name = "AccessDeniedError";
}
}
/**
* Build fetch options with init data header, Accept-Language and timeout abort.
* Optional external signal aborts this request when triggered.
*/
function buildFetchOptions(
initData: string,
acceptLang: ApiLang,
externalSignal?: AbortSignal | null
): { headers: HeadersInit; signal: AbortSignal; cleanup: () => void } {
const headers: Record<string, string> = {
"Accept-Language": acceptLang || "en",
};
if (initData) {
headers["X-Telegram-Init-Data"] = initData;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const onAbort = (): void => controller.abort();
const cleanup = (): void => {
clearTimeout(timeoutId);
if (externalSignal) {
externalSignal.removeEventListener("abort", onAbort);
}
};
if (externalSignal) {
if (externalSignal.aborted) {
cleanup();
controller.abort();
} else {
externalSignal.addEventListener("abort", onAbort);
}
}
return { headers, signal: controller.signal, cleanup };
}
/**
* Fetch duties for date range. Throws AccessDeniedError on 403.
* Rethrows AbortError when the request is cancelled (e.g. stale load).
*/
export async function fetchDuties(
from: string,
to: string,
initData: string,
acceptLang: ApiLang,
signal?: AbortSignal | null
): Promise<DutyWithUser[]> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/duties?${new URLSearchParams({ from, to }).toString()}`;
const opts = buildFetchOptions(initData, acceptLang, signal);
try {
logger.debug("API request", "/api/duties", { from, to });
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
logger.warn("Access denied", from, to);
let detail = translate(acceptLang, "access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) {
throw new Error(translate(acceptLang, "error_load_failed"));
}
const data = await res.json();
return validateDuties(data, acceptLang);
} catch (e) {
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
throw e;
}
logger.error("API request failed", "/api/duties", e);
throw e;
} finally {
opts.cleanup();
}
}
/**
* Fetch calendar events for range. Throws AccessDeniedError on 403.
* Returns [] on other non-200. Rethrows AbortError when the request is cancelled.
*/
export async function fetchCalendarEvents(
from: string,
to: string,
initData: string,
acceptLang: ApiLang,
signal?: AbortSignal | null
): Promise<CalendarEvent[]> {
const base =
typeof window !== "undefined" ? window.location.origin : "";
const url = `${base}/api/calendar-events?${new URLSearchParams({ from, to }).toString()}`;
const opts = buildFetchOptions(initData, acceptLang, signal);
try {
logger.debug("API request", "/api/calendar-events", { from, to });
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) {
logger.warn("Access denied", from, to, "calendar-events");
let detail = translate(acceptLang, "access_denied");
try {
const body = await res.json();
if (body && (body as { detail?: string }).detail !== undefined) {
const d = (body as { detail: string | { msg?: string } }).detail;
detail =
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
}
} catch {
/* ignore */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
}
if (!res.ok) return [];
const data = await res.json();
return validateCalendarEvents(data);
} catch (e) {
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
throw e;
}
logger.error("API request failed", "/api/calendar-events", e);
return [];
} finally {
opts.cleanup();
}
}

View File

@@ -0,0 +1,114 @@
/**
* Unit tests for calendar-data: dutiesByDate (including edge case end_at < start_at),
* calendarEventsByDate. Ported from webapp/js/calendar.test.js.
*/
import { describe, it, expect } from "vitest";
import { dutiesByDate, calendarEventsByDate } from "./calendar-data";
import type { DutyWithUser } from "@/types";
import type { CalendarEvent } from "@/types";
describe("dutiesByDate", () => {
it("groups duty by single local day", () => {
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
full_name: "Alice",
start_at: "2025-02-25T09:00:00Z",
end_at: "2025-02-25T18:00:00Z",
event_type: "duty",
phone: null,
username: null,
},
];
const byDate = dutiesByDate(duties);
expect(byDate["2025-02-25"]).toHaveLength(1);
expect(byDate["2025-02-25"][0].full_name).toBe("Alice");
});
it("spans duty across multiple days", () => {
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
full_name: "Bob",
start_at: "2025-02-25T00:00:00Z",
end_at: "2025-02-27T23:59:59Z",
event_type: "duty",
phone: null,
username: null,
},
];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeGreaterThanOrEqual(2);
keys.forEach((k) => expect(byDate[k]).toHaveLength(1));
expect(byDate[keys[0]][0].full_name).toBe("Bob");
});
it("skips duty when end_at < start_at (no infinite loop)", () => {
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
full_name: "Bad",
start_at: "2025-02-28T12:00:00Z",
end_at: "2025-02-25T08:00:00Z",
event_type: "duty",
phone: null,
username: null,
},
];
const byDate = dutiesByDate(duties);
expect(Object.keys(byDate)).toHaveLength(0);
});
it("does not iterate more than MAX_DAYS_PER_DUTY", () => {
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
full_name: "Long",
start_at: "2025-01-01T00:00:00Z",
end_at: "2026-06-01T00:00:00Z",
event_type: "duty",
phone: null,
username: null,
},
];
const byDate = dutiesByDate(duties);
const keys = Object.keys(byDate).sort();
expect(keys.length).toBeLessThanOrEqual(367);
});
it("handles empty duties", () => {
expect(dutiesByDate([])).toEqual({});
});
});
describe("calendarEventsByDate", () => {
it("maps events to local date key by UTC date", () => {
const events: CalendarEvent[] = [
{ date: "2025-02-25", summary: "Holiday" },
{ date: "2025-02-25", summary: "Meeting" },
{ date: "2025-02-26", summary: "Other" },
];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]);
expect(byDate["2025-02-26"]).toEqual(["Other"]);
});
it("skips events without summary", () => {
const events = [
{ date: "2025-02-25", summary: null as unknown as string },
];
const byDate = calendarEventsByDate(events);
expect(byDate["2025-02-25"] ?? []).toHaveLength(0);
});
it("handles null or undefined events", () => {
expect(calendarEventsByDate(null)).toEqual({});
expect(calendarEventsByDate(undefined)).toEqual({});
});
});

View File

@@ -0,0 +1,50 @@
/**
* Pure functions: group duties and calendar events by local date (YYYY-MM-DD).
* Ported from webapp/js/calendar.js (dutiesByDate, calendarEventsByDate).
*/
import type { CalendarEvent, DutyWithUser } from "@/types";
import { localDateString } from "./date-utils";
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
const MAX_DAYS_PER_DUTY = 366;
/**
* Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local.
*/
export function calendarEventsByDate(
events: CalendarEvent[] | null | undefined
): Record<string, string[]> {
const byDate: Record<string, string[]> = {};
(events ?? []).forEach((e) => {
const utcMidnight = new Date(e.date + "T00:00:00Z");
const key = localDateString(utcMidnight);
if (!byDate[key]) byDate[key] = [];
if (e.summary) byDate[key].push(e.summary);
});
return byDate;
}
/**
* Group duties by local date (start_at/end_at are UTC).
*/
export function dutiesByDate(duties: DutyWithUser[]): Record<string, DutyWithUser[]> {
const byDate: Record<string, DutyWithUser[]> = {};
duties.forEach((d) => {
const start = new Date(d.start_at);
const end = new Date(d.end_at);
if (end < start) return;
const endLocal = localDateString(end);
let cursor = new Date(start);
let iterations = 0;
while (iterations <= MAX_DAYS_PER_DUTY) {
const key = localDateString(cursor);
if (!byDate[key]) byDate[key] = [];
byDate[key].push(d);
if (key === endLocal) break;
cursor.setDate(cursor.getDate() + 1);
iterations++;
}
});
return byDate;
}

View File

@@ -1,7 +1,9 @@
/** /**
* Application constants and static labels. * Application constants for API and retry behaviour.
*/ */
export const FETCH_TIMEOUT_MS = 15000; export const FETCH_TIMEOUT_MS = 15000;
export const RETRY_DELAY_MS = 800; export const RETRY_DELAY_MS = 800;
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200; export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
export const RETRY_AFTER_ERROR_MS = 800;
export const MAX_GENERAL_RETRIES = 2;

View File

@@ -0,0 +1,87 @@
/**
* Unit tests for current-duty: getRemainingTime, findCurrentDuty.
* Ported from webapp/js/currentDuty.test.js.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { getRemainingTime, findCurrentDuty } from "./current-duty";
import type { DutyWithUser } from "@/types";
describe("getRemainingTime", () => {
it("returns hours and minutes until end from now", () => {
const endAt = "2025-03-02T17:30:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(5);
expect(minutes).toBe(30);
});
it("returns 0 when end is in the past", () => {
const endAt = "2025-03-02T09:00:00.000Z";
vi.useFakeTimers();
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
const { hours, minutes } = getRemainingTime(endAt);
vi.useRealTimers();
expect(hours).toBe(0);
expect(minutes).toBe(0);
});
it("returns 0 hours and 0 minutes when endAt is invalid", () => {
const { hours, minutes } = getRemainingTime("not-a-date");
expect(hours).toBe(0);
expect(minutes).toBe(0);
});
});
describe("findCurrentDuty", () => {
it("returns duty when now is between start_at and end_at", () => {
const now = new Date();
const start = new Date(now);
start.setHours(start.getHours() - 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1, 0, 0, 0);
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
event_type: "duty",
full_name: "Иванов",
start_at: start.toISOString(),
end_at: end.toISOString(),
phone: null,
username: null,
},
];
const duty = findCurrentDuty(duties);
expect(duty).not.toBeNull();
expect(duty?.full_name).toBe("Иванов");
});
it("returns null when no duty overlaps current time", () => {
const duties: DutyWithUser[] = [
{
id: 1,
user_id: 1,
event_type: "duty",
full_name: "Past",
start_at: "2020-01-01T09:00:00Z",
end_at: "2020-01-01T17:00:00Z",
phone: null,
username: null,
},
{
id: 2,
user_id: 2,
event_type: "duty",
full_name: "Future",
start_at: "2030-01-01T09:00:00Z",
end_at: "2030-01-01T17:00:00Z",
phone: null,
username: null,
},
];
expect(findCurrentDuty(duties)).toBeNull();
});
});

View File

@@ -0,0 +1,41 @@
/**
* Current duty helpers: remaining time and active duty lookup.
* Ported from webapp/js/currentDuty.js (getRemainingTime, findCurrentDuty).
*/
import type { DutyWithUser } from "@/types";
/**
* Compute remaining time until end of shift. Call only when now < end (active duty).
*
* @param endAt - ISO end time of the shift
* @returns Object with hours and minutes remaining
*/
export function getRemainingTime(endAt: string | Date): { hours: number; minutes: number } {
const end = new Date(endAt).getTime();
if (isNaN(end)) return { hours: 0, minutes: 0 };
const now = Date.now();
const ms = Math.max(0, end - now);
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
return { hours, minutes };
}
/**
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
*
* @param duties - List of duties with start_at, end_at, event_type
* @returns The active duty or null
*/
export function findCurrentDuty(duties: DutyWithUser[] | null | undefined): DutyWithUser | null {
const list = duties ?? [];
const dutyType = list.filter((d) => d.event_type === "duty");
const candidates = dutyType.length > 0 ? dutyType : list;
const now = Date.now();
for (const d of candidates) {
const start = new Date(d.start_at).getTime();
const end = new Date(d.end_at).getTime();
if (start <= now && now < end) return d;
}
return null;
}

View File

@@ -1,6 +1,5 @@
/** /**
* Unit tests for dateUtils: localDateString, dutyOverlapsLocalDay, * Unit tests for date-utils. Ported from webapp/js/dateUtils.test.js.
* dutyOverlapsLocalRange, getMonday, formatHHMM.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -14,7 +13,7 @@ import {
lastDayOfMonth, lastDayOfMonth,
formatDateKey, formatDateKey,
dateKeyToDDMM, dateKeyToDDMM,
} from "./dateUtils.js"; } from "./date-utils";
describe("localDateString", () => { describe("localDateString", () => {
it("formats date as YYYY-MM-DD", () => { it("formats date as YYYY-MM-DD", () => {
@@ -139,16 +138,21 @@ describe("formatHHMM", () => {
const result = formatHHMM(s); const result = formatHHMM(s);
expect(result).toMatch(/^\d{2}:\d{2}$/); expect(result).toMatch(/^\d{2}:\d{2}$/);
const d = new Date(s); const d = new Date(s);
const expected = (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes(); const expected =
(d.getHours() < 10 ? "0" : "") +
d.getHours() +
":" +
(d.getMinutes() < 10 ? "0" : "") +
d.getMinutes();
expect(result).toBe(expected); expect(result).toBe(expected);
}); });
it("returns empty string for null", () => { it("returns empty string for null", () => {
expect(formatHHMM(null)).toBe(""); expect(formatHHMM(null as unknown as string)).toBe("");
}); });
it("returns empty string for undefined", () => { it("returns empty string for undefined", () => {
expect(formatHHMM(undefined)).toBe(""); expect(formatHHMM(undefined as unknown as string)).toBe("");
}); });
it("returns empty string for empty string", () => { it("returns empty string for empty string", () => {
@@ -227,4 +231,10 @@ describe("dateKeyToDDMM", () => {
it("handles single-digit day and month", () => { it("handles single-digit day and month", () => {
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01"); expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
}); });
it("returns key unchanged when format is not YYYY-MM-DD", () => {
expect(dateKeyToDDMM("short")).toBe("short");
expect(dateKeyToDDMM("2025/02/25")).toBe("2025/02/25");
expect(dateKeyToDDMM("20250225")).toBe("20250225");
});
}); });

View File

@@ -1,26 +1,28 @@
/** /**
* Date/time helpers for calendar and duty display. * Date/time helpers for calendar and duty display.
* Ported from webapp/js/dateUtils.js.
*/ */
/** /**
* YYYY-MM-DD in local time (for calendar keys, "today", request range). * YYYY-MM-DD in local time (for calendar keys, "today", request range).
* @param {Date} d - Date
* @returns {string}
*/ */
export function localDateString(d) { export function localDateString(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0"); const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0");
return y + "-" + m + "-" + day; return `${y}-${m}-${day}`;
}
/** Duty-like object with start_at and end_at (UTC ISO strings). */
export interface DutyLike {
start_at: string;
end_at: string;
} }
/** /**
* True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD. * True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD.
* @param {object} d - Duty with start_at, end_at
* @param {string} dateKey - YYYY-MM-DD
* @returns {boolean}
*/ */
export function dutyOverlapsLocalDay(d, dateKey) { export function dutyOverlapsLocalDay(d: DutyLike, dateKey: string): boolean {
const [y, m, day] = dateKey.split("-").map(Number); const [y, m, day] = dateKey.split("-").map(Number);
const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0); const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0);
const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999); const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999);
@@ -31,12 +33,12 @@ export function dutyOverlapsLocalDay(d, dateKey) {
/** /**
* True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive). * True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive).
* @param {object} d - Duty with start_at, end_at
* @param {string} firstKey - YYYY-MM-DD (first day of range)
* @param {string} lastKey - YYYY-MM-DD (last day of range)
* @returns {boolean}
*/ */
export function dutyOverlapsLocalRange(d, firstKey, lastKey) { export function dutyOverlapsLocalRange(
d: DutyLike,
firstKey: string,
lastKey: string
): boolean {
const [y1, m1, day1] = firstKey.split("-").map(Number); const [y1, m1, day1] = firstKey.split("-").map(Number);
const [y2, m2, day2] = lastKey.split("-").map(Number); const [y2, m2, day2] = lastKey.split("-").map(Number);
const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0); const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0);
@@ -46,18 +48,16 @@ export function dutyOverlapsLocalRange(d, firstKey, lastKey) {
return end > rangeStart && start < rangeEnd; return end > rangeStart && start < rangeEnd;
} }
/** @param {Date} d - Date */ export function firstDayOfMonth(d: Date): Date {
export function firstDayOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth(), 1); return new Date(d.getFullYear(), d.getMonth(), 1);
} }
/** @param {Date} d - Date */ export function lastDayOfMonth(d: Date): Date {
export function lastDayOfMonth(d) {
return new Date(d.getFullYear(), d.getMonth() + 1, 0); return new Date(d.getFullYear(), d.getMonth() + 1, 0);
} }
/** @param {Date} d - Date (returns Monday of that week) */ /** Returns Monday of the week for the given date. */
export function getMonday(d) { export function getMonday(d: Date): Date {
const day = d.getDay(); const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1); const diff = d.getDate() - day + (day === 0 ? -6 : 1);
return new Date(d.getFullYear(), d.getMonth(), diff); return new Date(d.getFullYear(), d.getMonth(), diff);
@@ -65,31 +65,27 @@ export function getMonday(d) {
/** /**
* Format UTC date from ISO string as DD.MM for display. * Format UTC date from ISO string as DD.MM for display.
* @param {string} isoDateStr - ISO date string
* @returns {string} DD.MM
*/ */
export function formatDateKey(isoDateStr) { export function formatDateKey(isoDateStr: string): string {
const d = new Date(isoDateStr); const d = new Date(isoDateStr);
const day = String(d.getDate()).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0"); const month = String(d.getMonth() + 1).padStart(2, "0");
return day + "." + month; return `${day}.${month}`;
} }
/** /**
* Format YYYY-MM-DD as DD.MM for list header. * Format YYYY-MM-DD as DD.MM for list header.
* @param {string} key - YYYY-MM-DD * Returns key unchanged if it does not match YYYY-MM-DD format.
* @returns {string} DD.MM
*/ */
export function dateKeyToDDMM(key) { export function dateKeyToDDMM(key: string): string {
if (key.length < 10 || key[4] !== "-" || key[7] !== "-") return key;
return key.slice(8, 10) + "." + key.slice(5, 7); return key.slice(8, 10) + "." + key.slice(5, 7);
} }
/** /**
* Format ISO string as HH:MM (local). * Format ISO string as HH:MM (local).
* @param {string} isoStr - ISO date string
* @returns {string} HH:MM or ""
*/ */
export function formatHHMM(isoStr) { export function formatHHMM(isoStr: string): string {
if (!isoStr) return ""; if (!isoStr) return "";
const d = new Date(isoStr); const d = new Date(isoStr);
const h = d.getHours(); const h = d.getHours();

View File

@@ -0,0 +1,114 @@
/**
* Unit tests for getDutyMarkerRows (time prefix and order).
* Ported from webapp/js/hints.test.js.
*/
import { describe, it, expect } from "vitest";
import { getDutyMarkerRows } from "./duty-marker-rows";
import type { DutyWithUser } from "@/types";
const FROM = "from";
const TO = "until";
const SEP = "\u00a0";
function duty(
full_name: string,
start_at: string,
end_at: string
): DutyWithUser {
return {
id: 1,
user_id: 1,
full_name,
start_at,
end_at,
event_type: "duty",
phone: null,
username: null,
};
}
describe("getDutyMarkerRows", () => {
it("preserves input order (caller must sort by start_at before passing)", () => {
const hintDay = "2025-02-25";
const duties = [
duty("Иванов", "2025-02-25T14:00:00", "2025-02-25T18:00:00"),
duty("Петров", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
];
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[1].fullName).toBe("Петров");
});
it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => {
const hintDay = "2025-02-25";
const duties = [
duty("Иванов", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
duty("Петров", "2025-02-25T14:00:00", "2025-02-25T18:00:00"),
].sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[0].timePrefix).toContain("14:00");
expect(rows[0].timePrefix).toContain(FROM);
expect(rows[0].timePrefix).toContain(TO);
});
it("first of multiple continuation from previous day shows only end time", () => {
const hintDay = "2025-02-25";
const duties = [
duty("Иванов", "2025-02-24T22:00:00", "2025-02-25T06:00:00"),
duty("Петров", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
].sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("Иванов");
expect(rows[0].timePrefix).not.toContain(FROM);
expect(rows[0].timePrefix).toContain(TO);
expect(rows[0].timePrefix).toContain("06:00");
});
it("second duty continuation from previous day shows only end time (to HH:MM)", () => {
const hintDay = "2025-02-23";
const duties = [
duty("A", "2025-02-23T00:00:00", "2025-02-23T09:00:00"),
duty("B", "2025-02-22T09:00:00", "2025-02-23T09:00:00"),
];
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows).toHaveLength(2);
expect(rows[0].fullName).toBe("A");
expect(rows[0].timePrefix).toContain(FROM);
expect(rows[0].timePrefix).toContain("00:00");
expect(rows[0].timePrefix).toContain(TO);
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[1].fullName).toBe("B");
expect(rows[1].timePrefix).not.toContain(FROM);
expect(rows[1].timePrefix).toContain(TO);
expect(rows[1].timePrefix).toContain("09:00");
});
it("multiple duties in one day — correct order when input is pre-sorted", () => {
const hintDay = "2025-02-25";
const duties = [
duty("A", "2025-02-25T09:00:00", "2025-02-25T12:00:00"),
duty("B", "2025-02-25T12:00:00", "2025-02-25T15:00:00"),
duty("C", "2025-02-25T15:00:00", "2025-02-25T18:00:00"),
].sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]);
expect(rows[0].timePrefix).toContain("09:00");
expect(rows[1].timePrefix).toContain("12:00");
expect(rows[2].timePrefix).toContain("15:00");
});
});

View File

@@ -0,0 +1,97 @@
/**
* Build time-prefix rows for duty items in day detail (single source of time rules).
* Ported from webapp/js/hints.js getDutyMarkerRows and buildDutyItemTimePrefix.
*/
import { localDateString, formatHHMM } from "./date-utils";
import type { DutyWithUser } from "@/types";
export interface DutyMarkerRow {
id: number;
timePrefix: string;
fullName: string;
phone?: string | null;
username?: string | null;
}
function getItemFullName(item: DutyWithUser): string {
return item.full_name ?? "";
}
/**
* Build time prefix for one duty item (e.g. "from 09:00 until 18:00").
*/
function buildDutyItemTimePrefix(
item: DutyWithUser,
idx: number,
total: number,
hintDay: string,
sep: string,
fromLabel: string,
toLabel: string
): string {
const startAt = item.start_at;
const endAt = item.end_at;
const endHHMM = endAt ? formatHHMM(endAt) : "";
const startHHMM = startAt ? formatHHMM(startAt) : "";
const startSameDay =
hintDay && startAt && localDateString(new Date(startAt)) === hintDay;
const endSameDay =
hintDay && endAt && localDateString(new Date(endAt)) === hintDay;
let timePrefix = "";
if (idx === 0) {
if (total === 1 && startSameDay && startHHMM) {
timePrefix = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (startSameDay && startHHMM) {
timePrefix = fromLabel + sep + startHHMM;
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
timePrefix = toLabel + sep + endHHMM;
}
} else if (idx > 0) {
if (startSameDay && startHHMM) {
timePrefix = fromLabel + sep + startHHMM;
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
timePrefix += " " + toLabel + sep + endHHMM;
}
} else if (endHHMM) {
timePrefix = toLabel + sep + endHHMM;
}
}
return timePrefix;
}
/**
* Get array of { timePrefix, fullName, phone?, username? } for duty items.
*/
export function getDutyMarkerRows(
dutyItems: DutyWithUser[],
hintDay: string,
timeSep: string,
fromLabel: string,
toLabel: string
): DutyMarkerRow[] {
return dutyItems.map((item, idx) => {
const timePrefix = buildDutyItemTimePrefix(
item,
idx,
dutyItems.length,
hintDay,
timeSep,
fromLabel,
toLabel
);
return {
id: item.id,
timePrefix,
fullName: getItemFullName(item),
phone: item.phone,
username: item.username,
};
});
}

View File

@@ -0,0 +1,53 @@
/**
* Unit tests for launch-params: getStartParamFromUrl (deep link start_param).
*/
import { describe, it, expect, afterEach } from "vitest";
import { getStartParamFromUrl } from "./launch-params";
describe("getStartParamFromUrl", () => {
const origLocation = window.location;
afterEach(() => {
Object.defineProperty(window, "location", {
value: origLocation,
writable: true,
});
});
it("returns start_param from search", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, search: "?tgWebAppStartParam=duty", hash: "" },
writable: true,
});
expect(getStartParamFromUrl()).toBe("duty");
});
it("returns start_param from hash when not in search", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, search: "", hash: "#tgWebAppStartParam=duty" },
writable: true,
});
expect(getStartParamFromUrl()).toBe("duty");
});
it("prefers search over hash", () => {
Object.defineProperty(window, "location", {
value: {
...origLocation,
search: "?tgWebAppStartParam=calendar",
hash: "#tgWebAppStartParam=duty",
},
writable: true,
});
expect(getStartParamFromUrl()).toBe("calendar");
});
it("returns undefined when param is absent", () => {
Object.defineProperty(window, "location", {
value: { ...origLocation, search: "", hash: "" },
writable: true,
});
expect(getStartParamFromUrl()).toBeUndefined();
});
});

View File

@@ -0,0 +1,20 @@
/**
* Launch params from URL (query or hash). Used for deep links and initial view.
* Telegram may pass tgWebAppStartParam in search or hash; both are checked.
*/
/**
* Parse start_param from URL (query or hash). Use when SDK is not yet available
* (e.g. store init) or as fallback when SDK is delayed.
*/
export function getStartParamFromUrl(): string | undefined {
if (typeof window === "undefined") return undefined;
const fromSearch = new URLSearchParams(window.location.search).get(
"tgWebAppStartParam"
);
if (fromSearch) return fromSearch;
const fromHash = new URLSearchParams(window.location.hash.slice(1)).get(
"tgWebAppStartParam"
);
return fromHash ?? undefined;
}

View File

@@ -1,13 +1,12 @@
/** /**
* Unit tests for logger: level filtering and console delegation. * Unit tests for logger: level filtering and console delegation.
* Ported from webapp/js/logger.test.js.
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { logger } from "./logger.js"; import { logger } from "./logger";
describe("logger", () => { describe("logger", () => {
const origWindow = globalThis.window;
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, "debug").mockImplementation(() => {}); vi.spyOn(console, "debug").mockImplementation(() => {});
vi.spyOn(console, "info").mockImplementation(() => {}); vi.spyOn(console, "info").mockImplementation(() => {});
@@ -18,13 +17,13 @@ describe("logger", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
if (globalThis.window) { if (globalThis.window) {
delete globalThis.window.__DT_LOG_LEVEL; delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
} }
}); });
function setLevel(level) { function setLevel(level: string) {
if (!globalThis.window) globalThis.window = {}; if (!globalThis.window) (globalThis as unknown as { window: object }).window = {};
globalThis.window.__DT_LOG_LEVEL = level; (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL = level;
} }
it("at level info does not call console.debug", () => { it("at level info does not call console.debug", () => {
@@ -79,7 +78,7 @@ describe("logger", () => {
}); });
it("defaults to info when __DT_LOG_LEVEL is missing", () => { it("defaults to info when __DT_LOG_LEVEL is missing", () => {
if (globalThis.window) delete globalThis.window.__DT_LOG_LEVEL; if (globalThis.window) delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
logger.debug("no"); logger.debug("no");
expect(console.debug).not.toHaveBeenCalled(); expect(console.debug).not.toHaveBeenCalled();
logger.info("yes"); logger.info("yes");

View File

@@ -0,0 +1,62 @@
/**
* Frontend logger with configurable level (window.__DT_LOG_LEVEL).
* Ported from webapp/js/logger.js.
*/
const LEVEL_ORDER: Record<string, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
function getLogLevel(): string {
if (typeof window === "undefined") return "info";
const raw = (window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL ?? "info";
const level = String(raw).toLowerCase();
return Object.hasOwn(LEVEL_ORDER, level) ? level : "info";
}
function shouldLog(messageLevel: string): boolean {
const configured = getLogLevel();
const configuredNum = LEVEL_ORDER[configured] ?? 1;
const messageNum = LEVEL_ORDER[messageLevel] ?? 1;
return messageNum >= configuredNum;
}
const PREFIX = "[DutyTeller]";
function logAt(level: string, args: unknown[]): void {
if (!shouldLog(level)) return;
const consoleMethod =
level === "debug"
? console.debug
: level === "info"
? console.info
: level === "warn"
? console.warn
: console.error;
const prefix = `${PREFIX}[${level}]`;
if (args.length === 0) {
(consoleMethod as (a: string) => void)(prefix);
} else if (args.length === 1) {
(consoleMethod as (a: string, b: unknown) => void)(prefix, args[0]);
} else {
(consoleMethod as (a: string, ...b: unknown[]) => void)(prefix, ...args);
}
}
export const logger = {
debug(msg: unknown, ...args: unknown[]): void {
logAt("debug", [msg, ...args]);
},
info(msg: unknown, ...args: unknown[]): void {
logAt("info", [msg, ...args]);
},
warn(msg: unknown, ...args: unknown[]): void {
logAt("warn", [msg, ...args]);
},
error(msg: unknown, ...args: unknown[]): void {
logAt("error", [msg, ...args]);
},
};

View File

@@ -0,0 +1,36 @@
/**
* Unit tests for formatPhoneDisplay. Ported from webapp/js/contactHtml.test.js.
*/
import { describe, it, expect } from "vitest";
import { formatPhoneDisplay } from "./phone-format";
describe("formatPhoneDisplay", () => {
it("formats 11-digit number starting with 7", () => {
expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09");
expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09");
});
it("formats 11-digit number starting with 8", () => {
expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09");
});
it("formats 10-digit number as Russian", () => {
expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09");
});
it("returns empty string for null or empty", () => {
expect(formatPhoneDisplay(null)).toBe("");
expect(formatPhoneDisplay("")).toBe("");
expect(formatPhoneDisplay(" ")).toBe("");
});
it("strips non-digits before formatting", () => {
expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09");
});
it("returns digits as-is for non-10/11 length", () => {
expect(formatPhoneDisplay("123")).toBe("123");
expect(formatPhoneDisplay("12345678901")).toBe("12345678901");
});
});

View File

@@ -0,0 +1,38 @@
/**
* Phone number formatting for display.
* Ported from webapp/js/contactHtml.js (formatPhoneDisplay).
*
* Russian format: 79146522209 -> +7 914 652-22-09.
* Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX).
* Other lengths are returned as-is (digits only).
*/
export function formatPhoneDisplay(phone: string | null | undefined): string {
if (phone == null || String(phone).trim() === "") return "";
const digits = String(phone).replace(/\D/g, "");
if (digits.length === 10) {
return (
"+7 " +
digits.slice(0, 3) +
" " +
digits.slice(3, 6) +
"-" +
digits.slice(6, 8) +
"-" +
digits.slice(8)
);
}
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
const rest = digits.slice(1);
return (
"+7 " +
rest.slice(0, 3) +
" " +
rest.slice(3, 6) +
"-" +
rest.slice(6, 8) +
"-" +
rest.slice(8)
);
}
return digits;
}

View File

@@ -0,0 +1,53 @@
/**
* Unit tests for callMiniAppReadyOnce: single-call behaviour and missing SDK.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const isAvailableFn = vi.fn().mockReturnValue(true);
vi.mock("@telegram-apps/sdk-react", () => {
const readyFn = vi.fn();
return {
miniAppReady: Object.assign(readyFn, {
isAvailable: () => isAvailableFn(),
}),
};
});
describe("callMiniAppReadyOnce", () => {
beforeEach(() => {
vi.clearAllMocks();
isAvailableFn.mockReturnValue(true);
});
it("calls miniAppReady at most once per session", async () => {
vi.resetModules();
const { callMiniAppReadyOnce } = await import("./telegram-ready");
const { miniAppReady } = await import("@telegram-apps/sdk-react");
const readyCall = miniAppReady as ReturnType<typeof vi.fn>;
callMiniAppReadyOnce();
callMiniAppReadyOnce();
expect(readyCall).toHaveBeenCalledTimes(1);
});
it("does not throw when SDK is unavailable", async () => {
isAvailableFn.mockReturnValue(false);
vi.resetModules();
const { callMiniAppReadyOnce } = await import("./telegram-ready");
expect(() => callMiniAppReadyOnce()).not.toThrow();
});
it("does not call miniAppReady when isAvailable is false", async () => {
isAvailableFn.mockReturnValue(false);
vi.resetModules();
const { callMiniAppReadyOnce } = await import("./telegram-ready");
const { miniAppReady } = await import("@telegram-apps/sdk-react");
const readyCall = miniAppReady as ReturnType<typeof vi.fn>;
callMiniAppReadyOnce();
expect(readyCall).not.toHaveBeenCalled();
});
});

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