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:
@@ -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:
|
||||
- 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 |
|
||||
|--------|---------------|
|
||||
| `main.js` | Entry point: theme init, auth gate, `loadMonth`, navigation, swipe gestures, sticky scroll |
|
||||
| `dom.js` | Lazy DOM getters (`getCalendarEl()`, `getDutyListEl()`, etc.) and shared mutable `state` |
|
||||
| `i18n.js` | `MESSAGES` dictionary (en/ru), `getLang()`, `t()`, `monthName()`, `weekdayLabels()` |
|
||||
| `auth.js` | Telegram `initData` extraction (`getInitData`), `isLocalhost`, hash/query fallback |
|
||||
| `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` |
|
||||
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||
- **TypeScript**
|
||||
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||
|
||||
## 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
|
||||
export const state = {
|
||||
current: new Date(), // currently displayed month
|
||||
lastDutiesForList: [], // duties array for the duty list
|
||||
todayRefreshInterval: null, // interval handle
|
||||
lang: "en" // 'ru' | 'en'
|
||||
};
|
||||
```
|
||||
## Conventions
|
||||
|
||||
- **No store / pub-sub / reactivity.** `main.js` mutates `state`, then calls
|
||||
render functions (`renderCalendar`, `renderDutyList`) imperatively.
|
||||
- Other modules read `state` but should not mutate it directly.
|
||||
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
|
||||
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||
|
||||
## HTML rendering
|
||||
## Testing
|
||||
|
||||
- Rendering functions build HTML strings (concatenation + `escapeHtml`) or use
|
||||
`createElement` + `setAttribute`.
|
||||
- Always escape user-controlled text with `escapeHtml()` before inserting via `innerHTML`.
|
||||
- `setAttribute()` handles attribute quoting automatically — do not manually escape
|
||||
quotes for data attributes.
|
||||
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||
|
||||
## i18n
|
||||
|
||||
```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/`).
|
||||
Consider these rules when changing the Mini App or adding frontend features.
|
||||
|
||||
@@ -20,7 +20,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
||||
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
||||
│ Telegram │◄────────────►│ FastAPI (api/) │
|
||||
│ Mini App │ initData │ + static webapp │
|
||||
│ (webapp/) │ auth └──────────┬───────────┘
|
||||
│ (webapp-next/) │ auth └──────────┬───────────┘
|
||||
└──────────────┘ │
|
||||
┌──────────▼───────────┐
|
||||
│ SQLite + SQLAlchemy │
|
||||
@@ -31,7 +31,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
||||
- **Bot:** python-telegram-bot v22, async polling mode.
|
||||
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
||||
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
||||
- **Frontend:** 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
|
||||
|
||||
@@ -46,7 +46,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
||||
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
||||
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
||||
| `duty_teller/config.py` | Environment-based configuration |
|
||||
| `webapp/` | Telegram Mini App (HTML + JS + CSS) |
|
||||
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
|
||||
|
||||
## API endpoints
|
||||
|
||||
@@ -103,7 +103,7 @@ Triggered on `v*` tags:
|
||||
## Languages
|
||||
|
||||
- **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
|
||||
|
||||
## Version control
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
description: Rules for writing and running tests
|
||||
globs:
|
||||
- tests/**
|
||||
- webapp/js/**/*.test.js
|
||||
- webapp-next/src/**/*.test.{ts,tsx}
|
||||
---
|
||||
|
||||
# Testing
|
||||
@@ -51,48 +51,28 @@ def test_get_or_create_user_creates_new(test_db_url):
|
||||
assert user.telegram_user_id == 123
|
||||
```
|
||||
|
||||
## JavaScript tests (Vitest)
|
||||
## Frontend tests (Vitest + React Testing Library)
|
||||
|
||||
### Configuration
|
||||
|
||||
- Config: `webapp/vitest.config.js`.
|
||||
- Environment: `happy-dom` (lightweight DOM implementation).
|
||||
- Test files: `webapp/js/**/*.test.js`.
|
||||
- Run: `npm run test` (from `webapp/`).
|
||||
- Config: `webapp-next/vitest.config.ts`.
|
||||
- Environment: jsdom; React Testing Library for components.
|
||||
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
|
||||
- Setup: `webapp-next/src/test/setup.ts`.
|
||||
- Run: `cd webapp-next && npm test` (or `npm run test`).
|
||||
|
||||
### DOM setup
|
||||
### Writing frontend tests
|
||||
|
||||
Modules that import from `dom.js` expect DOM elements to exist at import time.
|
||||
Use `beforeAll` to set up the required HTML structure before the first import:
|
||||
|
||||
```js
|
||||
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.
|
||||
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
|
||||
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
|
||||
- Mock Telegram SDK and API fetch where necessary.
|
||||
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
|
||||
|
||||
### Example structure
|
||||
|
||||
```js
|
||||
```ts
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { localDateString } from "./dateUtils.js";
|
||||
import { localDateString } from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
|
||||
@@ -45,8 +45,6 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Webapp tests
|
||||
- name: Webapp (Next.js) build and test
|
||||
run: |
|
||||
cd webapp
|
||||
npm ci
|
||||
npm run test
|
||||
cd webapp-next && npm ci && npm test && npm run build
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -16,4 +16,9 @@ htmlcov/
|
||||
*.plan.md
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.log
|
||||
|
||||
# Next.js webapp
|
||||
webapp-next/out/
|
||||
webapp-next/node_modules/
|
||||
webapp-next/.next/
|
||||
@@ -4,7 +4,7 @@ This file is for AI assistants (e.g. Cursor) and maintainers. All project docume
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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/` |
|
||||
| Duty-schedule parser | `duty_teller/importers/` |
|
||||
| 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]`) |
|
||||
|
||||
## 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`
|
||||
- **Security:** `bandit -r duty_teller -ll`
|
||||
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,22 @@
|
||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
||||
# Multi-stage: webapp build (Next.js), Python builder, runtime.
|
||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||
|
||||
# --- Stage 1: builder (dependencies only) ---
|
||||
# --- Stage 1: webapp build (Next.js static export) ---
|
||||
FROM node:20-slim AS webapp-builder
|
||||
WORKDIR /webapp
|
||||
COPY webapp-next/package.json webapp-next/package-lock.json* ./
|
||||
RUN npm ci
|
||||
COPY webapp-next/ ./
|
||||
RUN npm run build
|
||||
|
||||
# --- Stage 2: builder (Python dependencies only) ---
|
||||
FROM python:3.12-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml ./
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
# --- Stage 2: runtime (minimal final image) ---
|
||||
# --- Stage 3: runtime (minimal final image) ---
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
COPY duty_teller/ ./duty_teller/
|
||||
COPY alembic/ ./alembic/
|
||||
COPY webapp/ ./webapp/
|
||||
COPY --from=webapp-builder /webapp/out ./webapp-next/out
|
||||
|
||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||
RUN adduser --disabled-password --gecos "" botuser \
|
||||
|
||||
@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
|
||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
||||
- `utils/` – Shared date, user, and handover helpers.
|
||||
- `importers/` – Duty-schedule JSON parser.
|
||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
||||
- `webapp-next/` – Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
|
||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
||||
## Components
|
||||
|
||||
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
|
||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
|
||||
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
||||
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
||||
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
||||
|
||||
@@ -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`. |
|
||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
||||
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
||||
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
|
||||
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
||||
| **HTTP_PORT** | integer (1–65535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
|
||||
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
||||
|
||||
@@ -128,18 +128,32 @@ def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
||||
return default
|
||||
|
||||
|
||||
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||
|
||||
|
||||
def _safe_tz_string(value: str) -> str:
|
||||
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
@app.get(
|
||||
"/app/config.js",
|
||||
summary="Mini App config (language, log level)",
|
||||
summary="Mini App config (language, log level, timezone)",
|
||||
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:
|
||||
"""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")
|
||||
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(
|
||||
content=body,
|
||||
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():
|
||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||
|
||||
@@ -111,6 +111,7 @@ class Settings:
|
||||
database_url: str
|
||||
bot_username: str
|
||||
mini_app_base_url: str
|
||||
mini_app_short_name: str
|
||||
http_host: str
|
||||
http_port: int
|
||||
allowed_usernames: set[str]
|
||||
@@ -168,6 +169,7 @@ class Settings:
|
||||
database_url=database_url,
|
||||
bot_username=bot_username,
|
||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "").strip().strip("/"),
|
||||
http_host=http_host,
|
||||
http_port=http_port,
|
||||
allowed_usernames=allowed,
|
||||
@@ -197,6 +199,7 @@ BOT_TOKEN = _settings.bot_token
|
||||
DATABASE_URL = _settings.database_url
|
||||
BOT_USERNAME = _settings.bot_username
|
||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||
HTTP_HOST = _settings.http_host
|
||||
HTTP_PORT = _settings.http_port
|
||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
||||
|
||||
@@ -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):
|
||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||
|
||||
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
|
||||
with start_param=duty (current duty view). Otherwise the link is to the bot with
|
||||
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
|
||||
start_param in some clients).
|
||||
"""
|
||||
if not config.BOT_USERNAME:
|
||||
return None
|
||||
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(
|
||||
text=t(lang, "pin_duty.view_contacts"),
|
||||
url=url,
|
||||
|
||||
@@ -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():
|
||||
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
|
||||
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
||||
config, "MINI_APP_SHORT_NAME", ""
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
@@ -107,6 +109,21 @@ def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||
|
||||
|
||||
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
||||
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
||||
from telegram import InlineKeyboardMarkup
|
||||
|
||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
||||
config, "MINI_APP_SHORT_NAME", "DutyApp"
|
||||
):
|
||||
with patch.object(mod, "t", return_value="View contacts"):
|
||||
result = mod._get_contact_button_markup("en")
|
||||
assert result is not None
|
||||
assert isinstance(result, InlineKeyboardMarkup)
|
||||
btn = result.inline_keyboard[0][0]
|
||||
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||
|
||||
41
webapp-next/.gitignore
vendored
Normal file
41
webapp-next/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
webapp-next/README.md
Normal file
36
webapp-next/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
23
webapp-next/components.json
Normal file
23
webapp-next/components.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
18
webapp-next/eslint.config.mjs
Normal file
18
webapp-next/eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
10
webapp-next/next.config.ts
Normal file
10
webapp-next/next.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "export",
|
||||
basePath: "/app",
|
||||
trailingSlash: true,
|
||||
images: { unoptimized: true },
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
13495
webapp-next/package-lock.json
generated
Normal file
13495
webapp-next/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
webapp-next/package.json
Normal file
45
webapp-next/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "webapp-next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.576.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^26.0.0",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
7
webapp-next/postcss.config.mjs
Normal file
7
webapp-next/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
BIN
webapp-next/src/app/favicon.ico
Normal file
BIN
webapp-next/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
52
webapp-next/src/app/global-error.tsx
Normal file
52
webapp-next/src/app/global-error.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Next.js root error boundary. Replaces the root layout when an unhandled error occurs.
|
||||
* Must define its own html/body. For most runtime errors the in-app AppErrorBoundary is used.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import "./globals.css";
|
||||
import { getLang, translate } from "@/i18n/messages";
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const lang = getLang();
|
||||
return (
|
||||
<html
|
||||
lang={lang === "ru" ? "ru" : "en"}
|
||||
data-theme="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
{/* Same theme detection as layout: hash / Telegram / prefers-color-scheme → data-theme */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{translate(lang, "error_boundary.message")}
|
||||
</h1>
|
||||
<p className="text-center text-muted-foreground">
|
||||
{translate(lang, "error_boundary.description")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => reset()}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{translate(lang, "error_boundary.reload")}
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
287
webapp-next/src/app/globals.css
Normal file
287
webapp-next/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
48
webapp-next/src/app/layout.tsx
Normal file
48
webapp-next/src/app/layout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { TelegramProvider } from "@/components/providers/TelegramProvider";
|
||||
import { AppErrorBoundary } from "@/components/AppErrorBoundary";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Duty Teller",
|
||||
description: "Team duty shift calendar and reminders",
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" data-theme="dark" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Inline script: theme from hash (tgWebAppColorScheme + all 14 TG themeParams → --tg-theme-*), then data-theme and Mini App colors. */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){var scheme='dark';var root=document.documentElement;var hash=typeof location!=='undefined'&&location.hash?location.hash.slice(1):'';if(hash){var params={};hash.split('&').forEach(function(p){var i=p.indexOf('=');if(i===-1)return;var k=decodeURIComponent(p.slice(0,i));var v=p.slice(i+1);params[k]=v;});if(params.tgWebAppColorScheme==='light'||params.tgWebAppColorScheme==='dark')scheme=params.tgWebAppColorScheme;if(params.tgWebAppThemeParams){try{var theme=JSON.parse(decodeURIComponent(params.tgWebAppThemeParams));var tgKeys=['bg_color','text_color','hint_color','link_color','button_color','button_text_color','secondary_bg_color','header_bg_color','accent_text_color','destructive_text_color','section_bg_color','section_header_text_color','section_separator_color','subtitle_text_color','bottom_bar_bg_color'];tgKeys.forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});Object.keys(theme).forEach(function(k){var v=theme[k];if(v&&typeof v==='string')root.style.setProperty('--tg-theme-'+k.replace(/_/g,'-'),v);});}catch(e){}}}var twa=typeof window!=='undefined'&&window.Telegram&&window.Telegram.WebApp;if(scheme!=='light'&&scheme!=='dark'&&twa&&(twa.colorScheme==='light'||twa.colorScheme==='dark'))scheme=twa.colorScheme;if(scheme!=='light'&&scheme!=='dark'){try{var s=getComputedStyle(root).getPropertyValue('--tg-color-scheme').trim();if(s==='light'||s==='dark')scheme=s;}catch(e){} }if(scheme!=='light'&&scheme!=='dark')scheme=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';root.setAttribute('data-theme',scheme);if(twa){if(twa.setBackgroundColor)twa.setBackgroundColor('bg_color');if(twa.setHeaderColor)twa.setHeaderColor('bg_color');}})();`,
|
||||
}}
|
||||
/>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){if(typeof window!=='undefined'&&window.__DT_LANG==null)window.__DT_LANG='en';})();`,
|
||||
}}
|
||||
/>
|
||||
<script src="/app/config.js" />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<TelegramProvider>
|
||||
<AppErrorBoundary>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</AppErrorBoundary>
|
||||
</TelegramProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
25
webapp-next/src/app/not-found.tsx
Normal file
25
webapp-next/src/app/not-found.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Next.js 404 page. Shown when notFound() is called or route is unknown.
|
||||
* For static export with a single route this is rarely hit; added for consistency.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
|
||||
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
||||
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{t("not_found.open_calendar")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
webapp-next/src/app/page.test.tsx
Normal file
54
webapp-next/src/app/page.test.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Integration test for main page: calendar and header visible, lang from store.
|
||||
* Ported from webapp/js/main.test.js applyLangToUi.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import Page from "./page";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-month-data", () => ({
|
||||
useMonthData: () => ({
|
||||
retry: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders calendar and header when store has default state", async () => {
|
||||
render(<Page />);
|
||||
expect(await screen.findByRole("grid", { name: "Calendar" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /previous month/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /next month/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sets document title and lang from store lang", async () => {
|
||||
useAppStore.getState().setLang("en");
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
expect(document.title).toBe("Duty Calendar");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
});
|
||||
|
||||
it("sets document title for ru when store lang is ru", async () => {
|
||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Календарь дежурств");
|
||||
});
|
||||
});
|
||||
});
|
||||
50
webapp-next/src/app/page.tsx
Normal file
50
webapp-next/src/app/page.tsx
Normal 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} />;
|
||||
}
|
||||
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
76
webapp-next/src/components/AppErrorBoundary.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Error boundary that catches render errors in the app tree and shows a fallback
|
||||
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
|
||||
* on React context that might be broken.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { translate } from "@/i18n/messages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface AppErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface AppErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Catches JavaScript errors in the child tree and renders a fallback UI
|
||||
* instead of crashing. Provides a Reload button to recover.
|
||||
*/
|
||||
export class AppErrorBoundary extends React.Component<
|
||||
AppErrorBoundaryProps,
|
||||
AppErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AppErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(): AppErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
if (typeof console !== "undefined" && console.error) {
|
||||
console.error("AppErrorBoundary caught an error:", error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
handleReload = (): void => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.reload();
|
||||
}
|
||||
};
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
const lang = getLang();
|
||||
const message = translate(lang, "error_boundary.message");
|
||||
const reloadLabel = translate(lang, "error_boundary.reload");
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={this.handleReload}
|
||||
className="bg-primary text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
{reloadLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
195
webapp-next/src/components/CalendarPage.tsx
Normal file
195
webapp-next/src/components/CalendarPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
79
webapp-next/src/components/calendar/CalendarDay.test.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Unit tests for CalendarDay: click opens day detail only for current month;
|
||||
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarDay", () => {
|
||||
const defaultProps = {
|
||||
dateKey: "2025-02-15",
|
||||
dayOfMonth: 15,
|
||||
isToday: false,
|
||||
duties: [],
|
||||
eventSummaries: [],
|
||||
onDayClick: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={false}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).toHaveBeenCalledTimes(1);
|
||||
expect(onDayClick).toHaveBeenCalledWith(
|
||||
"2025-02-15",
|
||||
expect.objectContaining({
|
||||
width: expect.any(Number),
|
||||
height: expect.any(Number),
|
||||
top: expect.any(Number),
|
||||
left: expect.any(Number),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
|
||||
const onDayClick = vi.fn();
|
||||
render(
|
||||
<CalendarDay
|
||||
{...defaultProps}
|
||||
isOtherMonth={true}
|
||||
onDayClick={onDayClick}
|
||||
/>
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onDayClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets aria-disabled on the button when isOtherMonth is true", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("is not disabled for interaction when isOtherMonth is false", () => {
|
||||
render(
|
||||
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
|
||||
);
|
||||
const button = screen.getByRole("button", { name: /15/ });
|
||||
expect(button.getAttribute("aria-disabled")).not.toBe("true");
|
||||
});
|
||||
});
|
||||
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
123
webapp-next/src/components/calendar/CalendarDay.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Single calendar day cell: date number and day indicators. Click opens day detail.
|
||||
* Ported from webapp/js/calendar.js day cell rendering.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { DayIndicators } from "./DayIndicators";
|
||||
|
||||
export interface CalendarDayProps {
|
||||
/** YYYY-MM-DD key for this day. */
|
||||
dateKey: string;
|
||||
/** Day of month (1–31) for display. */
|
||||
dayOfMonth: number;
|
||||
isToday: boolean;
|
||||
isOtherMonth: boolean;
|
||||
/** Duties overlapping this day (for indicators and tooltip). */
|
||||
duties: DutyWithUser[];
|
||||
/** External calendar event summaries for this day. */
|
||||
eventSummaries: string[];
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
function CalendarDayInner({
|
||||
dateKey,
|
||||
dayOfMonth,
|
||||
isToday,
|
||||
isOtherMonth,
|
||||
duties,
|
||||
eventSummaries,
|
||||
onDayClick,
|
||||
}: CalendarDayProps) {
|
||||
const { t } = useTranslation();
|
||||
const { dutyList, unavailableList, vacationList } = useMemo(
|
||||
() => ({
|
||||
dutyList: duties.filter((d) => d.event_type === "duty"),
|
||||
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
|
||||
vacationList: duties.filter((d) => d.event_type === "vacation"),
|
||||
}),
|
||||
[duties]
|
||||
);
|
||||
const hasEvent = eventSummaries.length > 0;
|
||||
const showIndicator = !isOtherMonth;
|
||||
const hasAny = duties.length > 0 || hasEvent;
|
||||
|
||||
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
|
||||
if (hasAny && showIndicator) {
|
||||
const counts: string[] = [];
|
||||
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
|
||||
if (unavailableList.length)
|
||||
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
|
||||
if (vacationList.length)
|
||||
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
|
||||
if (hasEvent) counts.push(t("hint.events"));
|
||||
ariaParts.push(counts.join(", "));
|
||||
} else {
|
||||
ariaParts.push(t("aria.day_info"));
|
||||
}
|
||||
const ariaLabel = ariaParts.join("; ");
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={isOtherMonth}
|
||||
data-date={dateKey}
|
||||
className={cn(
|
||||
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
|
||||
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
|
||||
isOtherMonth &&
|
||||
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
|
||||
!isOtherMonth && [
|
||||
"bg-surface hover:bg-[var(--surface-hover-10)]",
|
||||
"active:scale-[0.98] cursor-pointer",
|
||||
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
|
||||
],
|
||||
showIndicator && hasAny && "font-bold",
|
||||
showIndicator &&
|
||||
hasEvent &&
|
||||
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
|
||||
isToday &&
|
||||
hasEvent &&
|
||||
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (isOtherMonth) return;
|
||||
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
|
||||
}}
|
||||
>
|
||||
<span className="num">{dayOfMonth}</span>
|
||||
{showIndicator && (duties.length > 0 || hasEvent) && (
|
||||
<DayIndicators
|
||||
dutyCount={dutyList.length}
|
||||
unavailableCount={unavailableList.length}
|
||||
vacationCount={vacationList.length}
|
||||
hasEvents={hasEvent}
|
||||
isToday={isToday}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
|
||||
return (
|
||||
prev.dateKey === next.dateKey &&
|
||||
prev.dayOfMonth === next.dayOfMonth &&
|
||||
prev.isToday === next.isToday &&
|
||||
prev.isOtherMonth === next.isOtherMonth &&
|
||||
prev.duties === next.duties &&
|
||||
prev.eventSummaries === next.eventSummaries &&
|
||||
prev.onDayClick === next.onDayClick
|
||||
);
|
||||
}
|
||||
|
||||
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);
|
||||
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
88
webapp-next/src/components/calendar/CalendarGrid.test.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
|
||||
* Ported from webapp/js/calendar.test.js renderCalendar.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { CalendarGrid } from "./CalendarGrid";
|
||||
import { CalendarHeader } from "./CalendarHeader";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("CalendarGrid", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders 42 cells (6 weeks)", () => {
|
||||
const currentMonth = new Date(2025, 0, 1); // January 2025
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const cells = screen.getAllByRole("button", { name: /;/ });
|
||||
expect(cells.length).toBe(42);
|
||||
});
|
||||
|
||||
it("sets data-date on each cell to YYYY-MM-DD", () => {
|
||||
const currentMonth = new Date(2025, 0, 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const grid = screen.getByRole("grid", { name: "Calendar" });
|
||||
const buttons = grid.querySelectorAll('button[data-date]');
|
||||
expect(buttons.length).toBe(42);
|
||||
buttons.forEach((el) => {
|
||||
const date = el.getAttribute("data-date");
|
||||
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("adds today styling to cell matching today", () => {
|
||||
const today = new Date();
|
||||
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
render(
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
duties={[]}
|
||||
calendarEvents={[]}
|
||||
onDayClick={() => {}}
|
||||
/>
|
||||
);
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(today.getDate()).padStart(2, "0");
|
||||
const todayKey = `${year}-${month}-${day}`;
|
||||
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
|
||||
expect(todayCell).toBeTruthy();
|
||||
expect(todayCell?.className).toMatch(/today|bg-today/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CalendarHeader", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("sets month title from lang and given year/month", () => {
|
||||
render(
|
||||
<CalendarHeader
|
||||
month={new Date(2025, 1, 1)}
|
||||
onPrevMonth={() => {}}
|
||||
onNextMonth={() => {}}
|
||||
/>
|
||||
);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading).toHaveTextContent("February");
|
||||
expect(heading).toHaveTextContent("2025");
|
||||
});
|
||||
});
|
||||
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
93
webapp-next/src/components/calendar/CalendarGrid.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
|
||||
* Ported from webapp/js/calendar.js renderCalendar.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
getMonday,
|
||||
localDateString,
|
||||
} from "@/lib/date-utils";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CalendarDay } from "./CalendarDay";
|
||||
|
||||
export interface CalendarGridProps {
|
||||
/** Currently displayed month. */
|
||||
currentMonth: Date;
|
||||
/** All duties for the visible range (will be grouped by date). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
|
||||
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CELLS = 42;
|
||||
|
||||
export function CalendarGrid({
|
||||
currentMonth,
|
||||
duties,
|
||||
calendarEvents,
|
||||
onDayClick,
|
||||
className,
|
||||
}: CalendarGridProps) {
|
||||
const dutiesByDateMap = useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const calendarEventsByDateMap = useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
const todayKey = localDateString(new Date());
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const first = firstDayOfMonth(currentMonth);
|
||||
const start = getMonday(first);
|
||||
const result: { date: Date; key: string; month: number }[] = [];
|
||||
const d = new Date(start);
|
||||
for (let i = 0; i < CELLS; i++) {
|
||||
const key = localDateString(d);
|
||||
result.push({ date: new Date(d), key, month: d.getMonth() });
|
||||
d.setDate(d.getDate() + 1);
|
||||
}
|
||||
return result;
|
||||
}, [currentMonth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
|
||||
className
|
||||
)}
|
||||
role="grid"
|
||||
aria-label="Calendar"
|
||||
>
|
||||
{cells.map(({ date, key, month }) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
147
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal file
147
webapp-next/src/components/calendar/CalendarHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal file
70
webapp-next/src/components/calendar/DayIndicators.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
66
webapp-next/src/components/calendar/DayIndicators.tsx
Normal file
66
webapp-next/src/components/calendar/DayIndicators.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
webapp-next/src/components/calendar/index.ts
Normal file
12
webapp-next/src/components/calendar/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Calendar components and data helpers.
|
||||
*/
|
||||
|
||||
export { CalendarGrid } from "./CalendarGrid";
|
||||
export { CalendarHeader } from "./CalendarHeader";
|
||||
export { CalendarDay } from "./CalendarDay";
|
||||
export { DayIndicators } from "./DayIndicators";
|
||||
export type { DayIndicatorsProps } from "./DayIndicators";
|
||||
export type { CalendarGridProps } from "./CalendarGrid";
|
||||
export type { CalendarHeaderProps } from "./CalendarHeader";
|
||||
export type { CalendarDayProps } from "./CalendarDay";
|
||||
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
60
webapp-next/src/components/contact/ContactLinks.test.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
|
||||
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ContactLinks } from "./ContactLinks";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ContactLinks", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("returns null when phone and username are missing", () => {
|
||||
const { container } = render(
|
||||
<ContactLinks phone={null} username={null} />
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders phone only with label and tel: link", () => {
|
||||
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays phone formatted for Russian numbers", () => {
|
||||
render(<ContactLinks phone="79146522209" username={null} />);
|
||||
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders username only with label and t.me link", () => {
|
||||
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders both phone and username with labels", () => {
|
||||
render(
|
||||
<ContactLinks
|
||||
phone="+79001112233"
|
||||
username="bob"
|
||||
showLabels
|
||||
/>
|
||||
);
|
||||
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
|
||||
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/@bob/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("strips leading @ from username and displays with @", () => {
|
||||
render(<ContactLinks phone={null} username="@alice" />);
|
||||
const link = document.querySelector('a[href*="t.me/alice"]');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link?.textContent).toContain("@alice");
|
||||
});
|
||||
});
|
||||
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
145
webapp-next/src/components/contact/ContactLinks.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Contact links (phone, Telegram) for duty cards and day detail.
|
||||
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatPhoneDisplay } from "@/lib/phone-format";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
|
||||
|
||||
export interface ContactLinksProps {
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
layout?: "inline" | "block";
|
||||
showLabels?: boolean;
|
||||
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
|
||||
contextLabel?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const linkClass =
|
||||
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
|
||||
|
||||
/**
|
||||
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
|
||||
*/
|
||||
export function ContactLinks({
|
||||
phone,
|
||||
username,
|
||||
layout = "inline",
|
||||
showLabels = true,
|
||||
contextLabel,
|
||||
className,
|
||||
}: ContactLinksProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasPhone = Boolean(phone && String(phone).trim());
|
||||
const rawUsername = username && String(username).trim();
|
||||
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
|
||||
const hasUsername = Boolean(cleanUsername);
|
||||
|
||||
if (!hasPhone && !hasUsername) return null;
|
||||
|
||||
const ariaCall = contextLabel
|
||||
? t("contact.aria_call", { name: contextLabel })
|
||||
: t("contact.phone");
|
||||
const ariaTelegram = contextLabel
|
||||
? t("contact.aria_telegram", { name: contextLabel })
|
||||
: t("contact.telegram");
|
||||
|
||||
if (layout === "block") {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{hasPhone && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
|
||||
<PhoneIcon className="size-5" aria-hidden />
|
||||
<span>{formatPhoneDisplay(phone!)}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
{hasUsername && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
<TelegramIcon className="size-5" aria-hidden />
|
||||
<span>@{cleanUsername}</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (hasPhone) {
|
||||
const displayPhone = formatPhoneDisplay(phone!);
|
||||
parts.push(
|
||||
showLabels ? (
|
||||
<span key="phone">
|
||||
{t("contact.phone")}:{" "}
|
||||
<a
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<a
|
||||
key="phone"
|
||||
href={`tel:${String(phone).trim()}`}
|
||||
className={linkClass}
|
||||
aria-label={ariaCall}
|
||||
>
|
||||
{displayPhone}
|
||||
</a>
|
||||
)
|
||||
);
|
||||
}
|
||||
if (hasUsername) {
|
||||
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
|
||||
const link = (
|
||||
<a
|
||||
key="tg"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={linkClass}
|
||||
aria-label={ariaTelegram}
|
||||
>
|
||||
@{cleanUsername}
|
||||
</a>
|
||||
);
|
||||
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
|
||||
{parts.map((p, i) => (
|
||||
<span key={i} className="inline-flex items-center gap-x-1">
|
||||
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
webapp-next/src/components/contact/index.ts
Normal file
6
webapp-next/src/components/contact/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Contact links component.
|
||||
*/
|
||||
|
||||
export { ContactLinks } from "./ContactLinks";
|
||||
export type { ContactLinksProps } from "./ContactLinks";
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
|
||||
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { CurrentDutyView } from "./CurrentDutyView";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||
useTelegramAuth: () => ({
|
||||
initDataRaw: "test-init",
|
||||
startParam: undefined,
|
||||
isLocalhost: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchDuties: vi.fn().mockResolvedValue([]),
|
||||
AccessDeniedError: class AccessDeniedError extends Error {
|
||||
serverDetail?: string;
|
||||
constructor(m: string, d?: string) {
|
||||
super(m);
|
||||
this.serverDetail = d;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
describe("CurrentDutyView", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading then no-duty message when no active duty", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("back button calls onBack when clicked", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
|
||||
fireEvent.click(buttons[buttons.length - 1]);
|
||||
expect(onBack).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows Close button when openedFromPin is true", async () => {
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
|
||||
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows contact info not set when duty has no phone or username", async () => {
|
||||
const { fetchDuties } = await import("@/lib/api");
|
||||
const now = new Date();
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
|
||||
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
|
||||
const dutyNoContacts = {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
event_type: "duty" as const,
|
||||
full_name: "Test User",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
vi.mocked(fetchDuties).mockResolvedValue([dutyNoContacts]);
|
||||
const onBack = vi.fn();
|
||||
render(<CurrentDutyView onBack={onBack} />);
|
||||
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||
expect(
|
||||
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
|
||||
).toBeInTheDocument();
|
||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||
});
|
||||
});
|
||||
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
323
webapp-next/src/components/current-duty/CurrentDutyView.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
|
||||
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
|
||||
* and contact links. Integrates with Telegram BackButton.
|
||||
* Ported from webapp/js/currentDuty.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
import { fetchDuties, AccessDeniedError } from "@/lib/api";
|
||||
import {
|
||||
localDateString,
|
||||
dateKeyToDDMM,
|
||||
formatHHMM,
|
||||
} from "@/lib/date-utils";
|
||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
2
webapp-next/src/components/current-duty/index.ts
Normal file
2
webapp-next/src/components/current-duty/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CurrentDutyView } from "./CurrentDutyView";
|
||||
export type { CurrentDutyViewProps } from "./CurrentDutyView";
|
||||
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
77
webapp-next/src/components/day-detail/DayDetail.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Unit tests for DayDetailContent: sorts duties by start_at, 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();
|
||||
});
|
||||
});
|
||||
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
255
webapp-next/src/components/day-detail/DayDetail.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
|
||||
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
|
||||
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useIsDesktop } from "@/hooks/use-media-query";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
|
||||
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { DayDetailContent } from "./DayDetailContent";
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
|
||||
/** Empty state for day detail: date and "no duties or events" message. */
|
||||
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const todayKey = localDateString(new Date());
|
||||
const ddmm = dateKeyToDDMM(dateKey);
|
||||
const title =
|
||||
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2
|
||||
id="day-detail-title"
|
||||
className="text-[1.1rem] font-semibold leading-tight m-0"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0">
|
||||
{t("day_detail.no_events")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DayDetailHandle {
|
||||
/** Open the panel for the given day with popover anchored at the given rect. */
|
||||
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
|
||||
}
|
||||
|
||||
export interface DayDetailProps {
|
||||
/** All duties for the visible range (will be filtered by selectedDay). */
|
||||
duties: DutyWithUser[];
|
||||
/** All calendar events for the visible range. */
|
||||
calendarEvents: CalendarEvent[];
|
||||
/** Called when the panel should close. */
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
|
||||
*/
|
||||
function VirtualAnchor({
|
||||
rect,
|
||||
className,
|
||||
}: {
|
||||
rect: DOMRect;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("pointer-events-none fixed z-0", className)}
|
||||
style={{
|
||||
left: rect.left,
|
||||
top: rect.bottom,
|
||||
width: rect.width,
|
||||
height: 1,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
|
||||
const isDesktop = useIsDesktop();
|
||||
const { t } = useTranslation();
|
||||
const selectedDay = useAppStore((s) => s.selectedDay);
|
||||
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
|
||||
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
||||
const [exiting, setExiting] = React.useState(false);
|
||||
|
||||
const open = selectedDay !== null;
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
openWithRect(dateKey: string, rect: DOMRect) {
|
||||
setSelectedDay(dateKey);
|
||||
setAnchorRect(rect);
|
||||
},
|
||||
}),
|
||||
[setSelectedDay]
|
||||
);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setSelectedDay(null);
|
||||
setAnchorRect(null);
|
||||
setExiting(false);
|
||||
onClose();
|
||||
}, [setSelectedDay, onClose]);
|
||||
|
||||
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
|
||||
const requestClose = React.useCallback(() => {
|
||||
setExiting(true);
|
||||
}, []);
|
||||
|
||||
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
|
||||
React.useEffect(() => {
|
||||
if (!exiting) return;
|
||||
const fallback = window.setTimeout(() => {
|
||||
handleClose();
|
||||
}, 320);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [exiting, handleClose]);
|
||||
|
||||
const dutiesByDateMap = React.useMemo(
|
||||
() => dutiesByDate(duties),
|
||||
[duties]
|
||||
);
|
||||
const eventsByDateMap = React.useMemo(
|
||||
() => calendarEventsByDate(calendarEvents),
|
||||
[calendarEvents]
|
||||
);
|
||||
|
||||
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
|
||||
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
|
||||
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
|
||||
|
||||
// Close popover/sheet on window resize so anchor position does not become stale.
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onResize = () => handleClose();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [open, handleClose]);
|
||||
|
||||
const content =
|
||||
selectedDay &&
|
||||
(hasContent ? (
|
||||
<DayDetailContent
|
||||
dateKey={selectedDay}
|
||||
duties={dayDuties}
|
||||
eventSummaries={dayEvents}
|
||||
/>
|
||||
) : (
|
||||
<DayDetailEmpty dateKey={selectedDay} />
|
||||
));
|
||||
|
||||
if (!open || !selectedDay) return null;
|
||||
|
||||
const panelClassName =
|
||||
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
|
||||
const closeButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
|
||||
onClick={requestClose}
|
||||
aria-label={t("day_detail.close")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderSheet = (withHandle: boolean) => (
|
||||
<Sheet
|
||||
open={!exiting && open}
|
||||
onOpenChange={(o) => !o && requestClose()}
|
||||
>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh]",
|
||||
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);
|
||||
}
|
||||
);
|
||||
195
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal file
195
webapp-next/src/components/day-detail/DayDetailContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
webapp-next/src/components/day-detail/index.ts
Normal file
4
webapp-next/src/components/day-detail/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DayDetail } from "./DayDetail";
|
||||
export { DayDetailContent } from "./DayDetailContent";
|
||||
export type { DayDetailContentProps } from "./DayDetailContent";
|
||||
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";
|
||||
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
80
webapp-next/src/components/duty/DutyItem.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Single duty row: event type label, name, time range.
|
||||
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface DutyItemProps {
|
||||
duty: DutyWithUser;
|
||||
/** Override type label (e.g. "On duty now"). */
|
||||
typeLabelOverride?: string;
|
||||
/** Show "until HH:MM" instead of full range (for current duty). */
|
||||
showUntilEnd?: boolean;
|
||||
/** Extra class, e.g. for current duty highlight. */
|
||||
isCurrent?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const borderByType = {
|
||||
duty: "border-l-duty",
|
||||
unavailable: "border-l-unavailable",
|
||||
vacation: "border-l-vacation",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Renders type badge, name, and time. Timeline cards use event_type for border color.
|
||||
*/
|
||||
export function DutyItem({
|
||||
duty,
|
||||
typeLabelOverride,
|
||||
showUntilEnd = false,
|
||||
isCurrent = false,
|
||||
className,
|
||||
}: DutyItemProps) {
|
||||
const { t } = useTranslation();
|
||||
const typeLabel =
|
||||
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
|
||||
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
|
||||
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
|
||||
|
||||
let timeOrRange: string;
|
||||
if (showUntilEnd && duty.event_type === "duty") {
|
||||
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
|
||||
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
|
||||
const startStr = formatDateKey(duty.start_at);
|
||||
const endStr = formatDateKey(duty.end_at);
|
||||
timeOrRange = startStr === endStr ? startStr : `${startStr} – ${endStr}`;
|
||||
} else {
|
||||
timeOrRange = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
|
||||
"border-l-[3px] shadow-sm",
|
||||
"min-h-0",
|
||||
borderClass,
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
className
|
||||
)}
|
||||
data-slot="duty-item"
|
||||
>
|
||||
<span className="text-xs text-muted col-span-1 row-start-1">
|
||||
{typeLabel}
|
||||
</span>
|
||||
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
|
||||
{duty.full_name}
|
||||
</span>
|
||||
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
|
||||
{timeOrRange}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
webapp-next/src/components/duty/DutyList.test.tsx
Normal file
66
webapp-next/src/components/duty/DutyList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
256
webapp-next/src/components/duty/DutyList.tsx
Normal file
256
webapp-next/src/components/duty/DutyList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal file
188
webapp-next/src/components/duty/DutyTimelineCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
webapp-next/src/components/duty/index.ts
Normal file
10
webapp-next/src/components/duty/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Duty list and timeline components.
|
||||
*/
|
||||
|
||||
export { DutyList } from "./DutyList";
|
||||
export { DutyTimelineCard } from "./DutyTimelineCard";
|
||||
export { DutyItem } from "./DutyItem";
|
||||
export type { DutyListProps } from "./DutyList";
|
||||
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
|
||||
export type { DutyItemProps } from "./DutyItem";
|
||||
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
46
webapp-next/src/components/providers/TelegramProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
init,
|
||||
mountMiniAppSync,
|
||||
mountThemeParamsSync,
|
||||
bindThemeParamsCssVars,
|
||||
} from "@telegram-apps/sdk-react";
|
||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||
|
||||
/**
|
||||
* Wraps the app with Telegram Mini App SDK initialization.
|
||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||
* and mounts the mini app. Does not call ready() here — the app calls
|
||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||
*/
|
||||
export function TelegramProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const cleanup = init({ acceptCustomStyles: true });
|
||||
|
||||
if (mountThemeParamsSync.isAvailable()) {
|
||||
mountThemeParamsSync();
|
||||
}
|
||||
if (bindThemeParamsCssVars.isAvailable()) {
|
||||
bindThemeParamsCssVars();
|
||||
}
|
||||
fixSurfaceContrast();
|
||||
void document.documentElement.offsetHeight;
|
||||
|
||||
if (mountMiniAppSync.isAvailable()) {
|
||||
mountMiniAppSync();
|
||||
}
|
||||
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
24
webapp-next/src/components/states/AccessDenied.test.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AccessDenied } from "./AccessDenied";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("AccessDenied", () => {
|
||||
beforeEach(() => {
|
||||
resetAppStore();
|
||||
});
|
||||
|
||||
it("renders translated access denied message", () => {
|
||||
render(<AccessDenied serverDetail={null} />);
|
||||
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("appends serverDetail when provided", () => {
|
||||
render(<AccessDenied serverDetail="Custom 403 message" />);
|
||||
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
46
webapp-next/src/components/states/AccessDenied.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Access denied state: message and optional server detail.
|
||||
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AccessDeniedProps {
|
||||
/** Optional detail from API 403 response, shown below the main message. */
|
||||
serverDetail?: string | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays access denied message; optional second paragraph for server detail.
|
||||
*/
|
||||
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<p className="m-0 mb-2 font-semibold text-error">
|
||||
{t("access_denied")}
|
||||
</p>
|
||||
{hasDetail && (
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{serverDetail}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 m-0 text-sm text-muted">
|
||||
{t("access_denied.hint")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
26
webapp-next/src/components/states/ErrorState.test.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { ErrorState } from "./ErrorState";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("ErrorState", () => {
|
||||
beforeEach(() => resetAppStore());
|
||||
|
||||
it("renders error message", () => {
|
||||
render(<ErrorState message="Network error" onRetry={undefined} />);
|
||||
expect(screen.getByText("Network error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Retry button when onRetry provided", () => {
|
||||
const onRetry = vi.fn();
|
||||
render(<ErrorState message="Fail" onRetry={onRetry} />);
|
||||
const retry = screen.getByRole("button", { name: /retry|повторить/i });
|
||||
expect(retry).toBeInTheDocument();
|
||||
retry.click();
|
||||
expect(onRetry).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
75
webapp-next/src/components/states/ErrorState.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Error state: warning icon, message, and optional Retry button.
|
||||
* Ported from webapp/js/ui.js showError and states.css .error.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ErrorStateProps {
|
||||
/** Error message to display. If not provided, uses generic i18n message. */
|
||||
message?: string | null;
|
||||
/** Optional retry callback; when provided, a Retry button is shown. */
|
||||
onRetry?: (() => void) | null;
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Warning triangle icon 24×24 for error state. */
|
||||
function ErrorIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn("shrink-0 text-error", className)}
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
||||
<line x1="12" y1="9" x2="12" y2="13" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays an error message with optional Retry button.
|
||||
*/
|
||||
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayMessage =
|
||||
message && String(message).trim() ? message : t("error_generic");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
|
||||
className
|
||||
)}
|
||||
role="alert"
|
||||
>
|
||||
<ErrorIcon />
|
||||
<p className="m-0 text-sm font-medium">{displayMessage}</p>
|
||||
{typeof onRetry === "function" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-1 bg-primary text-primary-foreground hover:opacity-90 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
|
||||
onClick={onRetry}
|
||||
>
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
17
webapp-next/src/components/states/LoadingState.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Unit tests for LoadingState. Ported from webapp/js/ui.test.js (loading).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LoadingState } from "./LoadingState";
|
||||
import { resetAppStore } from "@/test/test-utils";
|
||||
|
||||
describe("LoadingState", () => {
|
||||
beforeEach(() => resetAppStore());
|
||||
|
||||
it("renders loading text", () => {
|
||||
render(<LoadingState />);
|
||||
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
71
webapp-next/src/components/states/LoadingState.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Loading state: spinner and translated "Loading…" text.
|
||||
* Optionally wraps content in a container for calendar placeholder use.
|
||||
* Ported from webapp CSS states.css .loading and index.html loading element.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LoadingStateProps {
|
||||
/** Optional class for the container. */
|
||||
className?: string;
|
||||
/** If true, render a compact skeleton-style placeholder (e.g. for calendar area). */
|
||||
asPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spinner icon matching original .loading__spinner (accent color, reduced-motion safe).
|
||||
*/
|
||||
function LoadingSpinner({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"block size-5 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none",
|
||||
"animate-spin",
|
||||
className
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full loading view: flex center, spinner + "Loading…" text.
|
||||
*/
|
||||
export function LoadingState({ className, asPlaceholder }: LoadingStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (asPlaceholder) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-[120px] items-center justify-center rounded-lg bg-muted/30",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2.5 py-3 text-center text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<LoadingSpinner />
|
||||
<span className="loading__text">{t("loading")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
webapp-next/src/components/states/index.ts
Normal file
7
webapp-next/src/components/states/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* State components: loading, error, access denied.
|
||||
*/
|
||||
|
||||
export { LoadingState } from "./LoadingState";
|
||||
export { ErrorState } from "./ErrorState";
|
||||
export { AccessDenied } from "./AccessDenied";
|
||||
48
webapp-next/src/components/ui/badge.tsx
Normal file
48
webapp-next/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
|
||||
outline:
|
||||
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 [a&]:hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot.Root : "span"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
data-variant={variant}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
64
webapp-next/src/components/ui/button.tsx
Normal file
64
webapp-next/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
92
webapp-next/src/components/ui/card.tsx
Normal file
92
webapp-next/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
89
webapp-next/src/components/ui/popover.tsx
Normal file
89
webapp-next/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
163
webapp-next/src/components/ui/sheet.tsx
Normal file
163
webapp-next/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
forceMount,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
forceMount={forceMount}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
onCloseAnimationEnd,
|
||||
onAnimationEnd,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
|
||||
onCloseAnimationEnd?: () => void
|
||||
}) {
|
||||
const useForceMount = Boolean(onCloseAnimationEnd)
|
||||
|
||||
const handleAnimationEnd = React.useCallback(
|
||||
(e: React.AnimationEvent<HTMLDivElement>) => {
|
||||
onAnimationEnd?.(e)
|
||||
if (e.currentTarget.getAttribute("data-state") === "closed") {
|
||||
onCloseAnimationEnd?.()
|
||||
}
|
||||
},
|
||||
[onAnimationEnd, onCloseAnimationEnd]
|
||||
)
|
||||
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay forceMount={useForceMount ? true : undefined} />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
forceMount={useForceMount ? true : undefined}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:duration-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,
|
||||
}
|
||||
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
13
webapp-next/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
57
webapp-next/src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-surface fill-surface" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
63
webapp-next/src/hooks/use-app-init.ts
Normal file
63
webapp-next/src/hooks/use-app-init.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Application initialization: language sync, access-denied logic, deep link routing.
|
||||
* Runs effects that depend on Telegram auth (isAllowed, startParam); caller provides those.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
|
||||
export interface UseAppInitParams {
|
||||
/** Whether the user is allowed (localhost or has valid initData). */
|
||||
isAllowed: boolean;
|
||||
/** Telegram Mini App start_param (e.g. "duty" for current duty deep link). */
|
||||
startParam: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs language from backend config, applies document lang/title, handles access denied
|
||||
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
||||
*/
|
||||
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||
const setLang = useAppStore((s) => s.setLang);
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||
const setLoading = useAppStore((s) => s.setLoading);
|
||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sync lang from backend config (window.__DT_LANG).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setLang(getLang());
|
||||
}, [setLang]);
|
||||
|
||||
// Apply lang to document (title and html lang) for accessibility and i18n.
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = lang;
|
||||
document.title = t("app.title");
|
||||
}, [lang, t]);
|
||||
|
||||
// When not allowed (no initData and not localhost), show access denied after delay.
|
||||
useEffect(() => {
|
||||
if (isAllowed) {
|
||||
setAccessDenied(false);
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
setAccessDenied(true);
|
||||
setLoading(false);
|
||||
}, RETRY_DELAY_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [isAllowed, setAccessDenied, setLoading]);
|
||||
|
||||
// When opened via deep link startParam=duty, show current duty view first.
|
||||
useEffect(() => {
|
||||
if (startParam === "duty") setCurrentView("currentDuty");
|
||||
}, [startParam, setCurrentView]);
|
||||
}
|
||||
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
29
webapp-next/src/hooks/use-auto-refresh.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 60-second interval to refresh duty list when viewing the current month.
|
||||
* Replaces state.todayRefreshInterval from webapp/js/main.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const AUTO_REFRESH_INTERVAL_MS = 60000;
|
||||
|
||||
/**
|
||||
* When isCurrentMonth is true, calls refresh() immediately, then every 60 seconds.
|
||||
* When isCurrentMonth becomes false or on unmount, the interval is cleared.
|
||||
*/
|
||||
export function useAutoRefresh(
|
||||
refresh: () => void,
|
||||
isCurrentMonth: boolean
|
||||
): void {
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCurrentMonth) return;
|
||||
refreshRef.current();
|
||||
const id = setInterval(() => refreshRef.current(), AUTO_REFRESH_INTERVAL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [isCurrentMonth]);
|
||||
}
|
||||
34
webapp-next/src/hooks/use-media-query.ts
Normal file
34
webapp-next/src/hooks/use-media-query.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Returns true when the given media query matches (e.g. min-width: 640px for desktop).
|
||||
* Used to switch DayDetail between Popover (desktop) and Sheet (mobile).
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Match a media query string (e.g. "(min-width: 640px)").
|
||||
* Returns undefined during SSR to avoid hydration mismatch; client gets the real value.
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean | undefined {
|
||||
const [matches, setMatches] = useState<boolean | undefined>(() =>
|
||||
typeof window === "undefined" ? undefined : window.matchMedia(query).matches
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mq = window.matchMedia(query);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
setMatches(mq.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/** True when viewport is at least 640px (desktop). Undefined during SSR. */
|
||||
export function useIsDesktop(): boolean | undefined {
|
||||
return useMediaQuery("(min-width: 640px)");
|
||||
}
|
||||
182
webapp-next/src/hooks/use-month-data.ts
Normal file
182
webapp-next/src/hooks/use-month-data.ts
Normal 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 };
|
||||
}
|
||||
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
44
webapp-next/src/hooks/use-sticky-scroll.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Toggles an "is-scrolled" class on the sticky element when the user has scrolled.
|
||||
* Replaces bindStickyScrollShadow from webapp/js/main.js.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const IS_SCROLLED_CLASS = "is-scrolled";
|
||||
|
||||
/**
|
||||
* Listens to window scroll and toggles the class "is-scrolled" on the given element
|
||||
* when window.scrollY > 0. Uses passive scroll listener.
|
||||
*/
|
||||
export function useStickyScroll(
|
||||
elementRef: React.RefObject<HTMLElement | null>
|
||||
): void {
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = elementRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const update = () => {
|
||||
rafRef.current = null;
|
||||
const scrolled = typeof window !== "undefined" && window.scrollY > 0;
|
||||
el.classList.toggle(IS_SCROLLED_CLASS, scrolled);
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
if (rafRef.current == null) {
|
||||
rafRef.current = requestAnimationFrame(update);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [elementRef]);
|
||||
}
|
||||
67
webapp-next/src/hooks/use-swipe.ts
Normal file
67
webapp-next/src/hooks/use-swipe.ts
Normal 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]);
|
||||
}
|
||||
50
webapp-next/src/hooks/use-telegram-auth.test.ts
Normal file
50
webapp-next/src/hooks/use-telegram-auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
61
webapp-next/src/hooks/use-telegram-auth.ts
Normal file
61
webapp-next/src/hooks/use-telegram-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
140
webapp-next/src/hooks/use-telegram-theme.test.ts
Normal file
140
webapp-next/src/hooks/use-telegram-theme.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
89
webapp-next/src/hooks/use-telegram-theme.ts
Normal file
89
webapp-next/src/hooks/use-telegram-theme.ts
Normal 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";
|
||||
}
|
||||
@@ -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 { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js";
|
||||
import {
|
||||
getLang,
|
||||
normalizeLang,
|
||||
translate,
|
||||
monthName,
|
||||
} from "./messages";
|
||||
|
||||
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(() => {
|
||||
if (typeof globalThis.window !== "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 {
|
||||
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", () => {
|
||||
globalThis.window.__DT_LANG = "ru";
|
||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||
expect(getLang()).toBe("ru");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -73,29 +78,29 @@ describe("normalizeLang", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("t", () => {
|
||||
describe("translate", () => {
|
||||
it("returns translation for existing key", () => {
|
||||
expect(t("en", "app.title")).toBe("Duty Calendar");
|
||||
expect(t("ru", "app.title")).toBe("Календарь дежурств");
|
||||
expect(translate("en", "app.title")).toBe("Duty Calendar");
|
||||
expect(translate("ru", "app.title")).toBe("Календарь дежурств");
|
||||
});
|
||||
|
||||
it("falls back to en when key missing in lang", () => {
|
||||
expect(t("ru", "app.title")).toBe("Календарь дежурств");
|
||||
expect(t("en", "loading")).toBe("Loading…");
|
||||
expect(translate("ru", "app.title")).toBe("Календарь дежурств");
|
||||
expect(translate("en", "loading")).toBe("Loading…");
|
||||
});
|
||||
|
||||
it("returns key when key missing in both", () => {
|
||||
expect(t("en", "missing.key")).toBe("missing.key");
|
||||
expect(t("ru", "unknown")).toBe("unknown");
|
||||
expect(translate("en", "missing.key")).toBe("missing.key");
|
||||
expect(translate("ru", "unknown")).toBe("unknown");
|
||||
});
|
||||
|
||||
it("replaces params placeholder", () => {
|
||||
expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
|
||||
expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
|
||||
expect(translate("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
|
||||
expect(translate("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
|
||||
});
|
||||
|
||||
it("handles empty params", () => {
|
||||
expect(t("en", "loading", {})).toBe("Loading…");
|
||||
expect(translate("en", "loading", {})).toBe("Loading…");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 const MESSAGES = {
|
||||
export type Lang = "ru" | "en";
|
||||
|
||||
export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
en: {
|
||||
"app.title": "Duty Calendar",
|
||||
loading: "Loading…",
|
||||
@@ -14,6 +16,8 @@ export const MESSAGES = {
|
||||
"error.retry": "Retry",
|
||||
"nav.prev_month": "Previous month",
|
||||
"nav.next_month": "Next month",
|
||||
"nav.today": "Today",
|
||||
"nav.refresh": "Refresh",
|
||||
"weekdays.mon": "Mon",
|
||||
"weekdays.tue": "Tue",
|
||||
"weekdays.wed": "Wed",
|
||||
@@ -43,6 +47,7 @@ export const MESSAGES = {
|
||||
"event_type.other": "Other",
|
||||
"duty.now_on_duty": "On duty now",
|
||||
"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.until": "until {time}",
|
||||
"hint.from": "from",
|
||||
@@ -50,16 +55,31 @@ export const MESSAGES = {
|
||||
"hint.duty_title": "Duty:",
|
||||
"hint.events": "Events:",
|
||||
"day_detail.close": "Close",
|
||||
"day_detail.no_events": "No duties or events this day.",
|
||||
"contact.label": "Contact",
|
||||
"contact.show": "Contacts",
|
||||
"contact.back": "Back",
|
||||
"contact.phone": "Phone",
|
||||
"contact.telegram": "Telegram",
|
||||
"contact.aria_call": "Call {name}",
|
||||
"contact.aria_telegram": "Message {name} on Telegram",
|
||||
"current_duty.title": "Current Duty",
|
||||
"current_duty.no_duty": "No one is on duty right now",
|
||||
"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.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: {
|
||||
"app.title": "Календарь дежурств",
|
||||
@@ -71,6 +91,8 @@ export const MESSAGES = {
|
||||
"error.retry": "Повторить",
|
||||
"nav.prev_month": "Предыдущий месяц",
|
||||
"nav.next_month": "Следующий месяц",
|
||||
"nav.today": "Сегодня",
|
||||
"nav.refresh": "Обновить",
|
||||
"weekdays.mon": "Пн",
|
||||
"weekdays.tue": "Вт",
|
||||
"weekdays.wed": "Ср",
|
||||
@@ -100,6 +122,7 @@ export const MESSAGES = {
|
||||
"event_type.other": "Другое",
|
||||
"duty.now_on_duty": "Сейчас дежурит",
|
||||
"duty.none_this_month": "В этом месяце дежурств нет.",
|
||||
"duty.none_this_month_hint": "Дежурства появятся после загрузки расписания.",
|
||||
"duty.today": "Сегодня",
|
||||
"duty.until": "до {time}",
|
||||
"hint.from": "с",
|
||||
@@ -107,35 +130,63 @@ export const MESSAGES = {
|
||||
"hint.duty_title": "Дежурство:",
|
||||
"hint.events": "События:",
|
||||
"day_detail.close": "Закрыть",
|
||||
"day_detail.no_events": "В этот день нет дежурств и событий.",
|
||||
"contact.label": "Контакт",
|
||||
"contact.show": "Контакты",
|
||||
"contact.back": "Назад",
|
||||
"contact.phone": "Телефон",
|
||||
"contact.telegram": "Telegram",
|
||||
"contact.aria_call": "Позвонить {name}",
|
||||
"contact.aria_telegram": "Написать {name} в Telegram",
|
||||
"current_duty.title": "Сейчас дежурит",
|
||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||
"current_duty.shift": "Смена",
|
||||
"current_duty.shift_tz": "Смена ({tz}):",
|
||||
"current_duty.shift_local": "Смена (ваше время):",
|
||||
"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 = [
|
||||
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun",
|
||||
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec"
|
||||
"month.jan",
|
||||
"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 = [
|
||||
"weekdays.mon", "weekdays.tue", "weekdays.wed", "weekdays.thu",
|
||||
"weekdays.fri", "weekdays.sat", "weekdays.sun"
|
||||
"weekdays.mon",
|
||||
"weekdays.tue",
|
||||
"weekdays.wed",
|
||||
"weekdays.thu",
|
||||
"weekdays.fri",
|
||||
"weekdays.sat",
|
||||
"weekdays.sun",
|
||||
];
|
||||
|
||||
/**
|
||||
* 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";
|
||||
const lower = code.toLowerCase();
|
||||
if (lower.startsWith("ru")) return "ru";
|
||||
@@ -145,71 +196,65 @@ export function normalizeLang(code) {
|
||||
/**
|
||||
* Get application language from backend config (window.__DT_LANG).
|
||||
* 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 =
|
||||
typeof window !== "undefined" && window.__DT_LANG != null
|
||||
? String(window.__DT_LANG)
|
||||
(window as unknown as { __DT_LANG?: string }).__DT_LANG != null
|
||||
? String((window as unknown as { __DT_LANG?: string }).__DT_LANG)
|
||||
: "";
|
||||
const lang = normalizeLang(raw);
|
||||
return lang === "ru" ? "ru" : "en";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
|
||||
* @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;
|
||||
function tEventType(lang: Lang, key: string): string {
|
||||
const dict = MESSAGES[lang] ?? MESSAGES.en;
|
||||
const enDict = MESSAGES.en;
|
||||
let s = dict[key];
|
||||
if (s === undefined) s = enDict[key];
|
||||
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;
|
||||
}
|
||||
|
||||
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];
|
||||
if (s === undefined) s = MESSAGES.en[key];
|
||||
if (s === undefined && key.startsWith("event_type.")) {
|
||||
return tEventType(lang, key);
|
||||
}
|
||||
if (s === undefined) return key;
|
||||
Object.keys(params).forEach((k) => {
|
||||
s = s.replace(new RegExp("\\{" + k + "\\}", "g"), params[k]);
|
||||
});
|
||||
for (const k of Object.keys(params)) {
|
||||
s = s.split(`{${k}}`).join(params[k]);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get month name by 0-based index.
|
||||
* @param {'ru'|'en'} lang
|
||||
* @param {number} month - 0–11
|
||||
* @returns {string}
|
||||
* Get month name by 0-based index (0–11).
|
||||
*/
|
||||
export function monthName(lang, month) {
|
||||
export function monthName(lang: Lang, month: number): string {
|
||||
const key = MONTH_KEYS[month];
|
||||
return key ? t(lang, key) : "";
|
||||
return key ? translate(lang, key) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weekday short labels (Mon–Sun order) for given lang.
|
||||
* @param {'ru'|'en'} lang
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function weekdayLabels(lang) {
|
||||
return WEEKDAY_KEYS.map((k) => t(lang, k));
|
||||
export function weekdayLabels(lang: Lang): string[] {
|
||||
return WEEKDAY_KEYS.map((k) => translate(lang, k));
|
||||
}
|
||||
24
webapp-next/src/i18n/use-translation.ts
Normal file
24
webapp-next/src/i18n/use-translation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
176
webapp-next/src/lib/api.test.ts
Normal file
176
webapp-next/src/lib/api.test.ts
Normal 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
209
webapp-next/src/lib/api.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
114
webapp-next/src/lib/calendar-data.test.ts
Normal file
114
webapp-next/src/lib/calendar-data.test.ts
Normal 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({});
|
||||
});
|
||||
});
|
||||
50
webapp-next/src/lib/calendar-data.ts
Normal file
50
webapp-next/src/lib/calendar-data.ts
Normal 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;
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* Application constants and static labels.
|
||||
* Application constants for API and retry behaviour.
|
||||
*/
|
||||
|
||||
export const FETCH_TIMEOUT_MS = 15000;
|
||||
export const RETRY_DELAY_MS = 800;
|
||||
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
||||
export const RETRY_AFTER_ERROR_MS = 800;
|
||||
export const MAX_GENERAL_RETRIES = 2;
|
||||
87
webapp-next/src/lib/current-duty.test.ts
Normal file
87
webapp-next/src/lib/current-duty.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
41
webapp-next/src/lib/current-duty.ts
Normal file
41
webapp-next/src/lib/current-duty.ts
Normal 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;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Unit tests for dateUtils: localDateString, dutyOverlapsLocalDay,
|
||||
* dutyOverlapsLocalRange, getMonday, formatHHMM.
|
||||
* Unit tests for date-utils. Ported from webapp/js/dateUtils.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
lastDayOfMonth,
|
||||
formatDateKey,
|
||||
dateKeyToDDMM,
|
||||
} from "./dateUtils.js";
|
||||
} from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
@@ -139,16 +138,21 @@ describe("formatHHMM", () => {
|
||||
const result = formatHHMM(s);
|
||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||
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);
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatHHMM(null)).toBe("");
|
||||
expect(formatHHMM(null as unknown as string)).toBe("");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
@@ -227,4 +231,10 @@ describe("dateKeyToDDMM", () => {
|
||||
it("handles single-digit day and month", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,26 +1,28 @@
|
||||
/**
|
||||
* 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).
|
||||
* @param {Date} d - Date
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localDateString(d) {
|
||||
export function localDateString(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).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.
|
||||
* @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 dayStart = new Date(y, m - 1, day, 0, 0, 0, 0);
|
||||
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).
|
||||
* @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 [y2, m2, day2] = lastKey.split("-").map(Number);
|
||||
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;
|
||||
}
|
||||
|
||||
/** @param {Date} d - Date */
|
||||
export function firstDayOfMonth(d) {
|
||||
export function firstDayOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
/** @param {Date} d - Date */
|
||||
export function lastDayOfMonth(d) {
|
||||
export function lastDayOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
}
|
||||
|
||||
/** @param {Date} d - Date (returns Monday of that week) */
|
||||
export function getMonday(d) {
|
||||
/** Returns Monday of the week for the given date. */
|
||||
export function getMonday(d: Date): Date {
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
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.
|
||||
* @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 day = String(d.getDate()).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.
|
||||
* @param {string} key - YYYY-MM-DD
|
||||
* @returns {string} DD.MM
|
||||
* Returns key unchanged if it does not match YYYY-MM-DD format.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 "";
|
||||
const d = new Date(isoStr);
|
||||
const h = d.getHours();
|
||||
114
webapp-next/src/lib/duty-marker-rows.test.ts
Normal file
114
webapp-next/src/lib/duty-marker-rows.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
97
webapp-next/src/lib/duty-marker-rows.ts
Normal file
97
webapp-next/src/lib/duty-marker-rows.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
53
webapp-next/src/lib/launch-params.test.ts
Normal file
53
webapp-next/src/lib/launch-params.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
20
webapp-next/src/lib/launch-params.ts
Normal file
20
webapp-next/src/lib/launch-params.ts
Normal 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;
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
/**
|
||||
* 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 { logger } from "./logger.js";
|
||||
import { logger } from "./logger";
|
||||
|
||||
describe("logger", () => {
|
||||
const origWindow = globalThis.window;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
@@ -18,13 +17,13 @@ describe("logger", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
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) {
|
||||
if (!globalThis.window) globalThis.window = {};
|
||||
globalThis.window.__DT_LOG_LEVEL = level;
|
||||
function setLevel(level: string) {
|
||||
if (!globalThis.window) (globalThis as unknown as { window: object }).window = {};
|
||||
(globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL = level;
|
||||
}
|
||||
|
||||
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", () => {
|
||||
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");
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
logger.info("yes");
|
||||
62
webapp-next/src/lib/logger.ts
Normal file
62
webapp-next/src/lib/logger.ts
Normal 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]);
|
||||
},
|
||||
};
|
||||
36
webapp-next/src/lib/phone-format.test.ts
Normal file
36
webapp-next/src/lib/phone-format.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
38
webapp-next/src/lib/phone-format.ts
Normal file
38
webapp-next/src/lib/phone-format.ts
Normal 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;
|
||||
}
|
||||
53
webapp-next/src/lib/telegram-ready.test.ts
Normal file
53
webapp-next/src/lib/telegram-ready.test.ts
Normal 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
Reference in New Issue
Block a user