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:
|
globs:
|
||||||
- webapp/**
|
- webapp-next/**
|
||||||
---
|
---
|
||||||
|
|
||||||
# Frontend — Telegram Mini App
|
# Frontend — Telegram Mini App (Next.js)
|
||||||
|
|
||||||
## Module structure
|
The Mini App lives in `webapp-next/`. It is built as a static export and served by FastAPI at `/app`.
|
||||||
|
|
||||||
All source lives in `webapp/js/`. Each module has a single responsibility:
|
## Stack
|
||||||
|
|
||||||
| Module | Responsibility |
|
- **Next.js** (App Router, `output: 'export'`, `basePath: '/app'`)
|
||||||
|--------|---------------|
|
- **TypeScript**
|
||||||
| `main.js` | Entry point: theme init, auth gate, `loadMonth`, navigation, swipe gestures, sticky scroll |
|
- **Tailwind CSS** — theme extended with custom tokens (surface, muted, accent, duty, today, etc.)
|
||||||
| `dom.js` | Lazy DOM getters (`getCalendarEl()`, `getDutyListEl()`, etc.) and shared mutable `state` |
|
- **shadcn/ui** — Button, Card, Sheet, Popover, Tooltip, Skeleton, Badge
|
||||||
| `i18n.js` | `MESSAGES` dictionary (en/ru), `getLang()`, `t()`, `monthName()`, `weekdayLabels()` |
|
- **Zustand** — app store (month, lang, duties, calendar events, loading, view state)
|
||||||
| `auth.js` | Telegram `initData` extraction (`getInitData`), `isLocalhost`, hash/query fallback |
|
- **@telegram-apps/sdk-react** — SDKProvider, useThemeParams, useLaunchParams, useMiniApp, useBackButton
|
||||||
| `theme.js` | Theme detection (`getTheme`), CSS variable injection (`applyThemeParamsToCss`), `initTheme` |
|
|
||||||
| `api.js` | `apiGet`, `fetchDuties`, `fetchCalendarEvents`; timeout, auth header, `Accept-Language` |
|
|
||||||
| `calendar.js` | `dutiesByDate`, `calendarEventsByDate`, `renderCalendar` (6-week grid with indicators) |
|
|
||||||
| `dutyList.js` | `dutyTimelineCardHtml`, `dutyItemHtml`, `renderDutyList` (monthly duty timeline cards) |
|
|
||||||
| `dayDetail.js` | Day detail panel — popover (desktop) or bottom sheet (mobile), `buildDayDetailContent` |
|
|
||||||
| `hints.js` | Tooltip positioning, duty marker hint content, info-button tooltips |
|
|
||||||
| `dateUtils.js` | Date helpers: `localDateString`, `dutyOverlapsLocalDay/Range`, `getMonday`, `formatHHMM`, etc. |
|
|
||||||
| `utils.js` | `escapeHtml` utility |
|
|
||||||
| `constants.js` | `FETCH_TIMEOUT_MS`, `RETRY_DELAY_MS`, `RETRY_AFTER_ACCESS_DENIED_MS` |
|
|
||||||
|
|
||||||
## State management
|
## Structure
|
||||||
|
|
||||||
A single mutable `state` object is exported from `dom.js`:
|
| Area | Location |
|
||||||
|
|------|----------|
|
||||||
|
| App entry, layout | `src/app/layout.tsx`, `src/app/page.tsx` |
|
||||||
|
| Providers | `src/components/providers/TelegramProvider.tsx` |
|
||||||
|
| Calendar | `src/components/calendar/` — CalendarHeader, CalendarGrid, CalendarDay, DayIndicators |
|
||||||
|
| Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
|
||||||
|
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
|
||||||
|
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
|
||||||
|
| Contact links | `src/components/contact/ContactLinks.tsx` |
|
||||||
|
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied |
|
||||||
|
| Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh |
|
||||||
|
| Lib | `src/lib/` — api, calendar-data, date-utils, phone-format, constants, utils |
|
||||||
|
| i18n | `src/i18n/` — messages.ts, use-translation.ts |
|
||||||
|
| Store | `src/store/app-store.ts` |
|
||||||
|
| Types | `src/types/index.ts` |
|
||||||
|
|
||||||
```js
|
## Conventions
|
||||||
export const state = {
|
|
||||||
current: new Date(), // currently displayed month
|
|
||||||
lastDutiesForList: [], // duties array for the duty list
|
|
||||||
todayRefreshInterval: null, // interval handle
|
|
||||||
lang: "en" // 'ru' | 'en'
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- **No store / pub-sub / reactivity.** `main.js` mutates `state`, then calls
|
- **Client components:** Use `'use client'` where hooks or browser APIs are used (layout loads config script; page and most UI are client).
|
||||||
render functions (`renderCalendar`, `renderDutyList`) imperatively.
|
- **Theme:** CSS variables in `globals.css`; `useTelegramTheme` maps Telegram theme params to `--tg-theme-*` and sets `data-theme` on `<html>`.
|
||||||
- Other modules read `state` but should not mutate it directly.
|
- **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost.
|
||||||
|
- **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend).
|
||||||
|
- **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED.
|
||||||
|
|
||||||
## HTML rendering
|
## Testing
|
||||||
|
|
||||||
- Rendering functions build HTML strings (concatenation + `escapeHtml`) or use
|
- **Runner:** Vitest in `webapp-next/`; environment: jsdom; React Testing Library.
|
||||||
`createElement` + `setAttribute`.
|
- **Config:** `webapp-next/vitest.config.ts`; setup in `src/test/setup.ts`.
|
||||||
- Always escape user-controlled text with `escapeHtml()` before inserting via `innerHTML`.
|
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||||
- `setAttribute()` handles attribute quoting automatically — do not manually escape
|
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||||
quotes for data attributes.
|
|
||||||
|
|
||||||
## i18n
|
Consider these rules when changing the Mini App or adding frontend features.
|
||||||
|
|
||||||
```js
|
|
||||||
t(lang, key, params?) // → translated string
|
|
||||||
```
|
|
||||||
|
|
||||||
- `lang` is `'ru'` or `'en'`, stored in `state.lang`.
|
|
||||||
- `getLang()` reads **backend config** only: `window.__DT_LANG` (set by `/app/config.js` from `DEFAULT_LANGUAGE`). If missing or invalid, falls back to `"en"`. No Telegram or navigator language detection.
|
|
||||||
- Params use named placeholders: `t(lang, "duty.until", { time: "14:00" })`.
|
|
||||||
- Fallback chain: `MESSAGES[lang][key]` → `MESSAGES.en[key]` → raw key string.
|
|
||||||
- All user-visible text must go through `t()` — never hardcode Russian strings in JS.
|
|
||||||
|
|
||||||
## Telegram WebApp SDK
|
|
||||||
|
|
||||||
- Loaded via `<script>` in `index.html` from `https://telegram.org/js/telegram-web-app.js`.
|
|
||||||
- `main.js` calls `Telegram.WebApp.ready()` and `expand()` on startup.
|
|
||||||
- Auth: `initData` from SDK → hash param `tgWebAppData` → query string (fallback chain in `auth.js`).
|
|
||||||
- The `X-Telegram-Init-Data` header carries initData to the backend API.
|
|
||||||
- `requireTelegramOrLocalhost` gates access; localhost is allowed for dev.
|
|
||||||
- Theme changes subscribed via `Telegram.WebApp.onEvent("theme_changed", applyTheme)`.
|
|
||||||
|
|
||||||
## Theme system
|
|
||||||
|
|
||||||
- CSS variables in `:root`: `--bg`, `--surface`, `--text`, `--muted`, `--accent`,
|
|
||||||
`--duty`, `--today`, `--unavailable`, `--vacation`, `--error`, etc.
|
|
||||||
- `[data-theme="light"]` and `[data-theme="dark"]` override base variables.
|
|
||||||
- `theme.js` resolves scheme via:
|
|
||||||
1. `Telegram.WebApp.colorScheme`
|
|
||||||
2. CSS `--tg-color-scheme`
|
|
||||||
3. `prefers-color-scheme` media query
|
|
||||||
- `applyThemeParamsToCss()` maps Telegram `themeParams` to `--tg-theme-*` CSS variables.
|
|
||||||
- Prefer `var(--token)` over hardcoded colors; use `color-mix()` for alpha variants.
|
|
||||||
|
|
||||||
## Testing (frontend)
|
|
||||||
|
|
||||||
- **Runner:** Vitest (`webapp/vitest.config.js`), environment: `happy-dom`.
|
|
||||||
- **Test files:** `webapp/js/**/*.test.js`.
|
|
||||||
- **DOM setup:** Element refs are resolved lazily (getters). Set `document.body.innerHTML`
|
|
||||||
with required DOM structure in `beforeAll`/`beforeEach`; import order of modules using `dom.js` is flexible.
|
|
||||||
- **Run:** `npm run test` (in `webapp/`).
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
|||||||
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
┌──────────────┐ HTTP ┌──────────▼───────────┐
|
||||||
│ Telegram │◄────────────►│ FastAPI (api/) │
|
│ Telegram │◄────────────►│ FastAPI (api/) │
|
||||||
│ Mini App │ initData │ + static webapp │
|
│ Mini App │ initData │ + static webapp │
|
||||||
│ (webapp/) │ auth └──────────┬───────────┘
|
│ (webapp-next/) │ auth └──────────┬───────────┘
|
||||||
└──────────────┘ │
|
└──────────────┘ │
|
||||||
┌──────────▼───────────┐
|
┌──────────▼───────────┐
|
||||||
│ SQLite + SQLAlchemy │
|
│ SQLite + SQLAlchemy │
|
||||||
@@ -31,7 +31,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
|||||||
- **Bot:** python-telegram-bot v22, async polling mode.
|
- **Bot:** python-telegram-bot v22, async polling mode.
|
||||||
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
- **API:** FastAPI served by uvicorn in a daemon thread alongside the bot.
|
||||||
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
- **Database:** SQLite via SQLAlchemy 2.x ORM; Alembic for migrations.
|
||||||
- **Frontend:** Vanilla JS Telegram Mini App at `/app`, no framework.
|
- **Frontend:** Next.js (TypeScript, Tailwind, shadcn/ui) static export at `/app`; source in `webapp-next/`.
|
||||||
|
|
||||||
## Key packages
|
## Key packages
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ with a Telegram Mini App (webapp) for calendar visualization.
|
|||||||
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
| `duty_teller/utils/` | Date helpers, user utilities, HTTP client |
|
||||||
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
| `duty_teller/cache.py` | TTL caches with pattern-based invalidation |
|
||||||
| `duty_teller/config.py` | Environment-based configuration |
|
| `duty_teller/config.py` | Environment-based configuration |
|
||||||
| `webapp/` | Telegram Mini App (HTML + JS + CSS) |
|
| `webapp-next/` | Telegram Mini App (Next.js, Tailwind, shadcn/ui; build → `out/`) |
|
||||||
|
|
||||||
## API endpoints
|
## API endpoints
|
||||||
|
|
||||||
@@ -103,7 +103,7 @@ Triggered on `v*` tags:
|
|||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
- **Backend:** Python 3.12+
|
- **Backend:** Python 3.12+
|
||||||
- **Frontend:** Vanilla JavaScript (ES modules, no bundler)
|
- **Frontend:** Next.js (TypeScript), Tailwind CSS, shadcn/ui; Vitest for tests
|
||||||
- **i18n:** Russian (default) and English
|
- **i18n:** Russian (default) and English
|
||||||
|
|
||||||
## Version control
|
## Version control
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
description: Rules for writing and running tests
|
description: Rules for writing and running tests
|
||||||
globs:
|
globs:
|
||||||
- tests/**
|
- tests/**
|
||||||
- webapp/js/**/*.test.js
|
- webapp-next/src/**/*.test.{ts,tsx}
|
||||||
---
|
---
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
@@ -51,48 +51,28 @@ def test_get_or_create_user_creates_new(test_db_url):
|
|||||||
assert user.telegram_user_id == 123
|
assert user.telegram_user_id == 123
|
||||||
```
|
```
|
||||||
|
|
||||||
## JavaScript tests (Vitest)
|
## Frontend tests (Vitest + React Testing Library)
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- Config: `webapp/vitest.config.js`.
|
- Config: `webapp-next/vitest.config.ts`.
|
||||||
- Environment: `happy-dom` (lightweight DOM implementation).
|
- Environment: jsdom; React Testing Library for components.
|
||||||
- Test files: `webapp/js/**/*.test.js`.
|
- Test files: `webapp-next/src/**/*.test.{ts,tsx}` (co-located or in test files).
|
||||||
- Run: `npm run test` (from `webapp/`).
|
- Setup: `webapp-next/src/test/setup.ts`.
|
||||||
|
- Run: `cd webapp-next && npm test` (or `npm run test`).
|
||||||
|
|
||||||
### DOM setup
|
### Writing frontend tests
|
||||||
|
|
||||||
Modules that import from `dom.js` expect DOM elements to exist at import time.
|
- Pure lib modules: unit test with Vitest (`describe` / `it` / `expect`).
|
||||||
Use `beforeAll` to set up the required HTML structure before the first import:
|
- React components: use `@testing-library/react` (render, screen, userEvent); wrap with required providers (e.g. TelegramProvider, store) via `src/test/test-utils.tsx` where needed.
|
||||||
|
- Mock Telegram SDK and API fetch where necessary.
|
||||||
```js
|
- File naming: `<module>.test.ts` or `<Component>.test.tsx`.
|
||||||
import { beforeAll, describe, it, expect } from "vitest";
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
document.body.innerHTML = `
|
|
||||||
<div id="calendar"></div>
|
|
||||||
<h2 id="monthTitle"></h2>
|
|
||||||
<button id="prevBtn"></button>
|
|
||||||
<button id="nextBtn"></button>
|
|
||||||
<div id="dutyList"></div>
|
|
||||||
<div id="dayDetail"></div>
|
|
||||||
<div id="accessDenied" class="hidden"></div>
|
|
||||||
<div id="errorBanner" class="hidden"></div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### Writing JS tests
|
|
||||||
|
|
||||||
- File naming: `webapp/js/<module>.test.js` (co-located with the source module).
|
|
||||||
- Test pure functions first (`dateUtils`, `i18n`, `utils`); mock DOM for render tests.
|
|
||||||
- Use `describe` blocks to group by function, `it` blocks for individual cases.
|
|
||||||
|
|
||||||
### Example structure
|
### Example structure
|
||||||
|
|
||||||
```js
|
```ts
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { localDateString } from "./dateUtils.js";
|
import { localDateString } from "./date-utils";
|
||||||
|
|
||||||
describe("localDateString", () => {
|
describe("localDateString", () => {
|
||||||
it("formats date as YYYY-MM-DD", () => {
|
it("formats date as YYYY-MM-DD", () => {
|
||||||
|
|||||||
@@ -45,8 +45,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "20"
|
node-version: "20"
|
||||||
|
|
||||||
- name: Webapp tests
|
- name: Webapp (Next.js) build and test
|
||||||
run: |
|
run: |
|
||||||
cd webapp
|
cd webapp-next && npm ci && npm test && npm run build
|
||||||
npm ci
|
|
||||||
npm run test
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -17,3 +17,8 @@ htmlcov/
|
|||||||
|
|
||||||
# Logs
|
# 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
|
## Project summary
|
||||||
|
|
||||||
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Vanilla JS webapp. The bot and web UI support Russian and English; configuration and docs are in English.
|
Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and group reminders. Stack: python-telegram-bot v22, FastAPI, SQLite (SQLAlchemy), Next.js (static export) Mini App. The bot and web UI support Russian and English; configuration and docs are in English.
|
||||||
|
|
||||||
## Key entry points
|
## Key entry points
|
||||||
|
|
||||||
@@ -23,12 +23,12 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
|
|||||||
| Translations (ru/en) | `duty_teller/i18n/` |
|
| Translations (ru/en) | `duty_teller/i18n/` |
|
||||||
| Duty-schedule parser | `duty_teller/importers/` |
|
| Duty-schedule parser | `duty_teller/importers/` |
|
||||||
| Config (env vars) | `duty_teller/config.py` |
|
| Config (env vars) | `duty_teller/config.py` |
|
||||||
| Miniapp frontend | `webapp/` |
|
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||||
|
|
||||||
## Running and testing
|
## Running and testing
|
||||||
|
|
||||||
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup.
|
- **Tests:** From repository root: `pytest`. Use `PYTHONPATH=.` if imports fail. See [CONTRIBUTING.md](CONTRIBUTING.md) and [README.md](README.md) for full setup. Frontend: `cd webapp-next && npm test && npm run build`.
|
||||||
- **Lint:** `ruff check duty_teller tests`
|
- **Lint:** `ruff check duty_teller tests`
|
||||||
- **Security:** `bandit -r duty_teller -ll`
|
- **Security:** `bandit -r duty_teller -ll`
|
||||||
|
|
||||||
|
|||||||
16
Dockerfile
16
Dockerfile
@@ -1,14 +1,22 @@
|
|||||||
# Multi-stage: builder installs deps; runtime copies only site-packages and app code.
|
# Multi-stage: webapp build (Next.js), Python builder, runtime.
|
||||||
# Single image for both dev and prod; Compose files differentiate behavior.
|
# Single image for both dev and prod; Compose files differentiate behavior.
|
||||||
|
|
||||||
# --- Stage 1: builder (dependencies only) ---
|
# --- Stage 1: webapp build (Next.js static export) ---
|
||||||
|
FROM node:20-slim AS webapp-builder
|
||||||
|
WORKDIR /webapp
|
||||||
|
COPY webapp-next/package.json webapp-next/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY webapp-next/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# --- Stage 2: builder (Python dependencies only) ---
|
||||||
FROM python:3.12-slim AS builder
|
FROM python:3.12-slim AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml ./
|
||||||
COPY duty_teller/ ./duty_teller/
|
COPY duty_teller/ ./duty_teller/
|
||||||
RUN pip install --no-cache-dir .
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
# --- Stage 2: runtime (minimal final image) ---
|
# --- Stage 3: runtime (minimal final image) ---
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -27,7 +35,7 @@ COPY main.py pyproject.toml entrypoint.sh ./
|
|||||||
RUN chmod +x entrypoint.sh
|
RUN chmod +x entrypoint.sh
|
||||||
COPY duty_teller/ ./duty_teller/
|
COPY duty_teller/ ./duty_teller/
|
||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY webapp/ ./webapp/
|
COPY --from=webapp-builder /webapp/out ./webapp-next/out
|
||||||
|
|
||||||
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
# Create data dir; entrypoint runs as root, fixes perms for volume, then runs app as botuser
|
||||||
RUN adduser --disabled-password --gecos "" botuser \
|
RUN adduser --disabled-password --gecos "" botuser \
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
|||||||
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
- `main.py` – Entry point: calls `duty_teller.run:main`. Alternatively, after `pip install -e .`, run the console command **`duty-teller`** (see `pyproject.toml` and `duty_teller/run.py`). The runner builds the `Application`, registers handlers, runs polling and FastAPI in a thread, and calls `duty_teller.config.require_bot_token()` so the app exits with a clear message if `BOT_TOKEN` is missing.
|
||||||
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
- `duty_teller/` – Main package (install with `pip install -e .`). Contains:
|
||||||
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
- `config.py` – Loads `BOT_TOKEN`, `DATABASE_URL`, `ALLOWED_USERNAMES`, etc. from env; no exit on import; use `require_bot_token()` in the entry point when running the bot. Optional `Settings` dataclass for tests. `PROJECT_ROOT` for webapp path.
|
||||||
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp`.
|
- `api/` – FastAPI app (`/api/duties`, `/api/calendar-events`), `dependencies.py` (DB session, auth, date validation), static webapp mounted from `PROJECT_ROOT/webapp-next/out` (built from `webapp-next/`).
|
||||||
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
- `db/` – SQLAlchemy models, session (`session_scope`), repository, schemas.
|
||||||
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
- `handlers/` – Telegram command and chat handlers; register via `register_handlers(app)`.
|
||||||
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
- `i18n/` – Translations and language detection (ru/en); used by handlers and API.
|
||||||
@@ -114,7 +114,7 @@ High-level architecture (components, data flow, package relationships) is descri
|
|||||||
- `utils/` – Shared date, user, and handover helpers.
|
- `utils/` – Shared date, user, and handover helpers.
|
||||||
- `importers/` – Duty-schedule JSON parser.
|
- `importers/` – Duty-schedule JSON parser.
|
||||||
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
- `alembic/` – Migrations; config in `pyproject.toml` under `[tool.alembic]`; URL and metadata from `duty_teller.config` and `duty_teller.db.models.Base`. Run: `alembic -c pyproject.toml upgrade head`.
|
||||||
- `webapp/` – Miniapp UI (calendar, duty list); served at `/app`.
|
- `webapp-next/` – Miniapp UI (Next.js, TypeScript, Tailwind, shadcn/ui); build output in `webapp-next/out/`, served at `/app`.
|
||||||
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
- `tests/` – Tests; `helpers.py` provides `make_init_data` for auth tests.
|
||||||
- `pyproject.toml` – Installable package (`pip install -e .`).
|
- `pyproject.toml` – Installable package (`pip install -e .`).
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
|||||||
## Components
|
## Components
|
||||||
|
|
||||||
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
- **Bot** — [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) v22 (Application API). Handles commands and group messages; runs in polling mode.
|
||||||
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app`. Runs in a separate thread alongside the bot.
|
- **FastAPI** — HTTP server: REST API (`/api/duties`, `/api/calendar-events`, `/api/calendar/ical/{token}.ics`) and static miniapp at `/app` (built from `webapp-next/`, Next.js static export). Runs in a separate thread alongside the bot.
|
||||||
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
- **Database** — SQLAlchemy ORM with Alembic migrations. Default backend: SQLite (`data/duty_teller.db`). Stores users, duties (with event types: duty, unavailable, vacation), group duty pins, calendar subscription tokens.
|
||||||
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
- **Duty-schedule import** — Two-step admin flow: handover time (timezone → UTC), then JSON file. Parser produces per-person date lists; import service deletes existing duties in range and inserts new ones.
|
||||||
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
- **Group duty pin** — In groups, the bot can pin the current duty message; time/timezone for the pinned text come from `DUTY_DISPLAY_TZ`. Pin state is restored on startup from the database. When the duty changes on schedule, the bot sends a new message, unpins the previous one and pins the new one; if `DUTY_PIN_NOTIFY` is enabled (default), pinning the new message triggers a Telegram notification for members. The first pin (bot added to group or `/pin_duty`) is always silent.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ All configuration is read from the environment (e.g. `.env` via python-dotenv).
|
|||||||
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
|
| **BOT_TOKEN** | string | *(empty)* | Telegram bot token from [@BotFather](https://t.me/BotFather). Required for the bot to run; if unset, the entry point exits with a clear message. The server that serves the Mini App API must use the **same** token as the bot; otherwise initData validation returns `hash_mismatch`. |
|
||||||
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
| **DATABASE_URL** | string (SQLAlchemy URL) | `sqlite:///data/duty_teller.db` | Database connection URL. Should start with `sqlite://` or `postgresql://`; a warning is logged at startup if the format is unexpected. Example: `sqlite:///data/duty_teller.db`. |
|
||||||
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
| **MINI_APP_BASE_URL** | string (URL, no trailing slash) | *(empty)* | Base URL of the miniapp (for documentation and CORS). Trailing slash is stripped. Example: `https://your-domain.com/app`. |
|
||||||
|
| **MINI_APP_SHORT_NAME** | string | *(empty)* | Short name of the Web App in BotFather (e.g. `DutyApp`). When set, the pinned duty message "View contacts" button uses a direct Mini App link `https://t.me/BotName/ShortName?startapp=duty` so the app opens on the current-duty view. If unset, the button uses `https://t.me/BotName?startapp=duty` (user may land in bot chat first). |
|
||||||
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
| **HTTP_HOST** | string | `127.0.0.1` | Host to bind the HTTP server to. Use `127.0.0.1` to listen only on localhost; use `0.0.0.0` to accept connections from all interfaces (e.g. when behind a reverse proxy on another machine). |
|
||||||
| **HTTP_PORT** | integer (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. |
|
| **HTTP_PORT** | integer (1–65535) | `8080` | Port for the HTTP server (FastAPI + static webapp). Invalid or out-of-range values are clamped; non-numeric values fall back to 8080. |
|
||||||
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
| **ALLOWED_USERNAMES** | comma-separated list | *(empty)* | **Not used for access.** Kept for reference only. Access to the miniapp is controlled by **roles in the DB** (assigned by an admin via `/set_role`). |
|
||||||
|
|||||||
@@ -128,18 +128,32 @@ def _safe_js_string(value: str, allowed: frozenset[str], default: str) -> str:
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Timezone for duty display: allow only safe chars (letters, digits, /, _, -, +) to prevent injection.
|
||||||
|
_TZ_SAFE_RE = re.compile(r"^[A-Za-z0-9_/+-]{1,50}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_tz_string(value: str) -> str:
|
||||||
|
"""Return value if it matches safe timezone pattern, else empty string."""
|
||||||
|
if value and _TZ_SAFE_RE.match(value.strip()):
|
||||||
|
return value.strip()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
"/app/config.js",
|
"/app/config.js",
|
||||||
summary="Mini App config (language, log level)",
|
summary="Mini App config (language, log level, timezone)",
|
||||||
description=(
|
description=(
|
||||||
"Returns JS that sets window.__DT_LANG and window.__DT_LOG_LEVEL. Loaded before main.js."
|
"Returns JS that sets window.__DT_LANG, window.__DT_LOG_LEVEL and window.__DT_TZ. "
|
||||||
|
"Loaded before main.js."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def app_config_js() -> Response:
|
def app_config_js() -> Response:
|
||||||
"""Return JS assigning window.__DT_LANG and window.__DT_LOG_LEVEL for the webapp. No caching."""
|
"""Return JS assigning window.__DT_LANG, __DT_LOG_LEVEL and __DT_TZ for the webapp. No caching."""
|
||||||
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
lang = _safe_js_string(config.DEFAULT_LANGUAGE, _VALID_LANGS, "en")
|
||||||
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
log_level = _safe_js_string(config.LOG_LEVEL_STR.lower(), _VALID_LOG_LEVELS, "info")
|
||||||
body = f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";'
|
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||||
|
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||||
|
body = f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
|
||||||
return Response(
|
return Response(
|
||||||
content=body,
|
content=body,
|
||||||
media_type="application/javascript; charset=utf-8",
|
media_type="application/javascript; charset=utf-8",
|
||||||
@@ -267,6 +281,7 @@ def get_personal_calendar_ical(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
webapp_path = config.PROJECT_ROOT / "webapp"
|
|
||||||
|
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||||
if webapp_path.is_dir():
|
if webapp_path.is_dir():
|
||||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ class Settings:
|
|||||||
database_url: str
|
database_url: str
|
||||||
bot_username: str
|
bot_username: str
|
||||||
mini_app_base_url: str
|
mini_app_base_url: str
|
||||||
|
mini_app_short_name: str
|
||||||
http_host: str
|
http_host: str
|
||||||
http_port: int
|
http_port: int
|
||||||
allowed_usernames: set[str]
|
allowed_usernames: set[str]
|
||||||
@@ -168,6 +169,7 @@ class Settings:
|
|||||||
database_url=database_url,
|
database_url=database_url,
|
||||||
bot_username=bot_username,
|
bot_username=bot_username,
|
||||||
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
mini_app_base_url=os.getenv("MINI_APP_BASE_URL", "").rstrip("/"),
|
||||||
|
mini_app_short_name=(os.getenv("MINI_APP_SHORT_NAME", "") or "").strip().strip("/"),
|
||||||
http_host=http_host,
|
http_host=http_host,
|
||||||
http_port=http_port,
|
http_port=http_port,
|
||||||
allowed_usernames=allowed,
|
allowed_usernames=allowed,
|
||||||
@@ -197,6 +199,7 @@ BOT_TOKEN = _settings.bot_token
|
|||||||
DATABASE_URL = _settings.database_url
|
DATABASE_URL = _settings.database_url
|
||||||
BOT_USERNAME = _settings.bot_username
|
BOT_USERNAME = _settings.bot_username
|
||||||
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
MINI_APP_BASE_URL = _settings.mini_app_base_url
|
||||||
|
MINI_APP_SHORT_NAME = _settings.mini_app_short_name
|
||||||
HTTP_HOST = _settings.http_host
|
HTTP_HOST = _settings.http_host
|
||||||
HTTP_PORT = _settings.http_port
|
HTTP_PORT = _settings.http_port
|
||||||
ALLOWED_USERNAMES = _settings.allowed_usernames
|
ALLOWED_USERNAMES = _settings.allowed_usernames
|
||||||
|
|||||||
@@ -87,9 +87,18 @@ def _get_contact_button_markup(lang: str) -> InlineKeyboardMarkup | None:
|
|||||||
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
Uses a t.me Mini App deep link so the app opens inside Telegram. Uses url (not web_app):
|
||||||
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
InlineKeyboardButton with web_app is allowed only in private chats, so in groups
|
||||||
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
Telegram returns Button_type_invalid. A plain URL button works everywhere.
|
||||||
|
|
||||||
|
When MINI_APP_SHORT_NAME is set, the URL is a direct Mini App link so the app opens
|
||||||
|
with start_param=duty (current duty view). Otherwise the link is to the bot with
|
||||||
|
?startapp=duty (user may land in bot chat; opening the app from menu does not pass
|
||||||
|
start_param in some clients).
|
||||||
"""
|
"""
|
||||||
if not config.BOT_USERNAME:
|
if not config.BOT_USERNAME:
|
||||||
return None
|
return None
|
||||||
|
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"
|
url = f"https://t.me/{config.BOT_USERNAME}?startapp=duty"
|
||||||
button = InlineKeyboardButton(
|
button = InlineKeyboardButton(
|
||||||
text=t(lang, "pin_duty.view_contacts"),
|
text=t(lang, "pin_duty.view_contacts"),
|
||||||
|
|||||||
@@ -90,10 +90,12 @@ def test_get_contact_button_markup_empty_username_returns_none():
|
|||||||
|
|
||||||
|
|
||||||
def test_get_contact_button_markup_returns_markup_when_username_set():
|
def test_get_contact_button_markup_returns_markup_when_username_set():
|
||||||
"""_get_contact_button_markup: BOT_USERNAME set -> returns InlineKeyboardMarkup with t.me deep link (startapp=duty)."""
|
"""_get_contact_button_markup: BOT_USERNAME set, no short name -> t.me bot link with startapp=duty."""
|
||||||
from telegram import InlineKeyboardMarkup
|
from telegram import InlineKeyboardMarkup
|
||||||
|
|
||||||
with patch.object(config, "BOT_USERNAME", "MyDutyBot"):
|
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
||||||
|
config, "MINI_APP_SHORT_NAME", ""
|
||||||
|
):
|
||||||
with patch.object(mod, "t", return_value="View contacts"):
|
with patch.object(mod, "t", return_value="View contacts"):
|
||||||
result = mod._get_contact_button_markup("en")
|
result = mod._get_contact_button_markup("en")
|
||||||
assert result is not None
|
assert result is not None
|
||||||
@@ -107,6 +109,21 @@ def test_get_contact_button_markup_returns_markup_when_username_set():
|
|||||||
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
assert btn.url == "https://t.me/MyDutyBot?startapp=duty"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_contact_button_markup_with_short_name_uses_direct_miniapp_link():
|
||||||
|
"""_get_contact_button_markup: MINI_APP_SHORT_NAME set -> direct Mini App URL with startapp=duty."""
|
||||||
|
from telegram import InlineKeyboardMarkup
|
||||||
|
|
||||||
|
with patch.object(config, "BOT_USERNAME", "MyDutyBot"), patch.object(
|
||||||
|
config, "MINI_APP_SHORT_NAME", "DutyApp"
|
||||||
|
):
|
||||||
|
with patch.object(mod, "t", return_value="View contacts"):
|
||||||
|
result = mod._get_contact_button_markup("en")
|
||||||
|
assert result is not None
|
||||||
|
assert isinstance(result, InlineKeyboardMarkup)
|
||||||
|
btn = result.inline_keyboard[0][0]
|
||||||
|
assert btn.url == "https://t.me/MyDutyBot/DutyApp?startapp=duty"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_schedule_next_update_job_queue_none_returns_early():
|
async def test_schedule_next_update_job_queue_none_returns_early():
|
||||||
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
"""_schedule_next_update: job_queue is None -> log and return, no run_once."""
|
||||||
|
|||||||
41
webapp-next/.gitignore
vendored
Normal file
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 { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
import { getLang, normalizeLang, t, monthName, MESSAGES } from "./i18n.js";
|
getLang,
|
||||||
|
normalizeLang,
|
||||||
|
translate,
|
||||||
|
monthName,
|
||||||
|
} from "./messages";
|
||||||
|
|
||||||
describe("getLang", () => {
|
describe("getLang", () => {
|
||||||
const orig__DT_LANG = globalThis.window?.__DT_LANG;
|
const orig__DT_LANG = (globalThis as unknown as { window?: { __DT_LANG?: string } }).window?.__DT_LANG;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
if (typeof globalThis.window !== "undefined") {
|
if (typeof globalThis.window !== "undefined") {
|
||||||
if (orig__DT_LANG !== undefined) {
|
if (orig__DT_LANG !== undefined) {
|
||||||
globalThis.window.__DT_LANG = orig__DT_LANG;
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = orig__DT_LANG;
|
||||||
} else {
|
} else {
|
||||||
delete globalThis.window.__DT_LANG;
|
delete (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns ru when window.__DT_LANG is ru", () => {
|
it("returns ru when window.__DT_LANG is ru", () => {
|
||||||
globalThis.window.__DT_LANG = "ru";
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||||
expect(getLang()).toBe("ru");
|
expect(getLang()).toBe("ru");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns en when window.__DT_LANG is en", () => {
|
it("returns en when window.__DT_LANG is en", () => {
|
||||||
globalThis.window.__DT_LANG = "en";
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "en";
|
||||||
expect(getLang()).toBe("en");
|
expect(getLang()).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns en when window.__DT_LANG is missing", () => {
|
it("returns en when window.__DT_LANG is missing", () => {
|
||||||
delete globalThis.window.__DT_LANG;
|
delete (globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG;
|
||||||
expect(getLang()).toBe("en");
|
expect(getLang()).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns en when window.__DT_LANG is invalid (unknown code)", () => {
|
it("returns en when window.__DT_LANG is invalid (unknown code)", () => {
|
||||||
globalThis.window.__DT_LANG = "uk";
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "uk";
|
||||||
expect(getLang()).toBe("en");
|
expect(getLang()).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => {
|
it("returns ru when window.__DT_LANG is ru-RU (normalized)", () => {
|
||||||
globalThis.window.__DT_LANG = "ru-RU";
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru-RU";
|
||||||
expect(getLang()).toBe("ru");
|
expect(getLang()).toBe("ru");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns en when window.__DT_LANG is empty string", () => {
|
it("returns en when window.__DT_LANG is empty string", () => {
|
||||||
globalThis.window.__DT_LANG = "";
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "";
|
||||||
expect(getLang()).toBe("en");
|
expect(getLang()).toBe("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns en when window.__DT_LANG is null", () => {
|
it("returns en when window.__DT_LANG is null", () => {
|
||||||
globalThis.window.__DT_LANG = null;
|
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = null as unknown as string;
|
||||||
expect(getLang()).toBe("en");
|
expect(getLang()).toBe("en");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -73,29 +78,29 @@ describe("normalizeLang", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("t", () => {
|
describe("translate", () => {
|
||||||
it("returns translation for existing key", () => {
|
it("returns translation for existing key", () => {
|
||||||
expect(t("en", "app.title")).toBe("Duty Calendar");
|
expect(translate("en", "app.title")).toBe("Duty Calendar");
|
||||||
expect(t("ru", "app.title")).toBe("Календарь дежурств");
|
expect(translate("ru", "app.title")).toBe("Календарь дежурств");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to en when key missing in lang", () => {
|
it("falls back to en when key missing in lang", () => {
|
||||||
expect(t("ru", "app.title")).toBe("Календарь дежурств");
|
expect(translate("ru", "app.title")).toBe("Календарь дежурств");
|
||||||
expect(t("en", "loading")).toBe("Loading…");
|
expect(translate("en", "loading")).toBe("Loading…");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns key when key missing in both", () => {
|
it("returns key when key missing in both", () => {
|
||||||
expect(t("en", "missing.key")).toBe("missing.key");
|
expect(translate("en", "missing.key")).toBe("missing.key");
|
||||||
expect(t("ru", "unknown")).toBe("unknown");
|
expect(translate("ru", "unknown")).toBe("unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces params placeholder", () => {
|
it("replaces params placeholder", () => {
|
||||||
expect(t("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
|
expect(translate("en", "duty.until", { time: "14:00" })).toBe("until 14:00");
|
||||||
expect(t("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
|
expect(translate("ru", "duty.until", { time: "09:30" })).toBe("до 09:30");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles empty params", () => {
|
it("handles empty params", () => {
|
||||||
expect(t("en", "loading", {})).toBe("Loading…");
|
expect(translate("en", "loading", {})).toBe("Loading…");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Internationalization: language from backend config (window.__DT_LANG) and translations.
|
* Internationalization: message dictionary (en/ru) and pure translation helpers.
|
||||||
|
* Ported from webapp/js/i18n.js. Language is read from window.__DT_LANG (backend config).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {Record<string, Record<string, string>>} */
|
export type Lang = "ru" | "en";
|
||||||
export const MESSAGES = {
|
|
||||||
|
export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||||
en: {
|
en: {
|
||||||
"app.title": "Duty Calendar",
|
"app.title": "Duty Calendar",
|
||||||
loading: "Loading…",
|
loading: "Loading…",
|
||||||
@@ -14,6 +16,8 @@ export const MESSAGES = {
|
|||||||
"error.retry": "Retry",
|
"error.retry": "Retry",
|
||||||
"nav.prev_month": "Previous month",
|
"nav.prev_month": "Previous month",
|
||||||
"nav.next_month": "Next month",
|
"nav.next_month": "Next month",
|
||||||
|
"nav.today": "Today",
|
||||||
|
"nav.refresh": "Refresh",
|
||||||
"weekdays.mon": "Mon",
|
"weekdays.mon": "Mon",
|
||||||
"weekdays.tue": "Tue",
|
"weekdays.tue": "Tue",
|
||||||
"weekdays.wed": "Wed",
|
"weekdays.wed": "Wed",
|
||||||
@@ -43,6 +47,7 @@ export const MESSAGES = {
|
|||||||
"event_type.other": "Other",
|
"event_type.other": "Other",
|
||||||
"duty.now_on_duty": "On duty now",
|
"duty.now_on_duty": "On duty now",
|
||||||
"duty.none_this_month": "No duties this month.",
|
"duty.none_this_month": "No duties this month.",
|
||||||
|
"duty.none_this_month_hint": "Duties will appear after the schedule is loaded.",
|
||||||
"duty.today": "Today",
|
"duty.today": "Today",
|
||||||
"duty.until": "until {time}",
|
"duty.until": "until {time}",
|
||||||
"hint.from": "from",
|
"hint.from": "from",
|
||||||
@@ -50,16 +55,31 @@ export const MESSAGES = {
|
|||||||
"hint.duty_title": "Duty:",
|
"hint.duty_title": "Duty:",
|
||||||
"hint.events": "Events:",
|
"hint.events": "Events:",
|
||||||
"day_detail.close": "Close",
|
"day_detail.close": "Close",
|
||||||
|
"day_detail.no_events": "No duties or events this day.",
|
||||||
"contact.label": "Contact",
|
"contact.label": "Contact",
|
||||||
"contact.show": "Contacts",
|
"contact.show": "Contacts",
|
||||||
"contact.back": "Back",
|
"contact.back": "Back",
|
||||||
"contact.phone": "Phone",
|
"contact.phone": "Phone",
|
||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
|
"contact.aria_call": "Call {name}",
|
||||||
|
"contact.aria_telegram": "Message {name} on Telegram",
|
||||||
"current_duty.title": "Current Duty",
|
"current_duty.title": "Current Duty",
|
||||||
"current_duty.no_duty": "No one is on duty right now",
|
"current_duty.no_duty": "No one is on duty right now",
|
||||||
"current_duty.shift": "Shift",
|
"current_duty.shift": "Shift",
|
||||||
|
"current_duty.shift_tz": "Shift ({tz}):",
|
||||||
|
"current_duty.shift_local": "Shift (your time):",
|
||||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||||
"current_duty.back": "Back to calendar"
|
"current_duty.ends_at": "Until end of shift at {time}",
|
||||||
|
"current_duty.back": "Back to calendar",
|
||||||
|
"current_duty.close": "Close",
|
||||||
|
"current_duty.contact_info_not_set": "Contact info not set",
|
||||||
|
"error_boundary.message": "Something went wrong.",
|
||||||
|
"error_boundary.description": "An unexpected error occurred. Try reloading the app.",
|
||||||
|
"error_boundary.reload": "Reload",
|
||||||
|
"not_found.title": "Page not found",
|
||||||
|
"not_found.description": "The page you are looking for does not exist.",
|
||||||
|
"not_found.open_calendar": "Open calendar",
|
||||||
|
"access_denied.hint": "Open the app again from Telegram.",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -71,6 +91,8 @@ export const MESSAGES = {
|
|||||||
"error.retry": "Повторить",
|
"error.retry": "Повторить",
|
||||||
"nav.prev_month": "Предыдущий месяц",
|
"nav.prev_month": "Предыдущий месяц",
|
||||||
"nav.next_month": "Следующий месяц",
|
"nav.next_month": "Следующий месяц",
|
||||||
|
"nav.today": "Сегодня",
|
||||||
|
"nav.refresh": "Обновить",
|
||||||
"weekdays.mon": "Пн",
|
"weekdays.mon": "Пн",
|
||||||
"weekdays.tue": "Вт",
|
"weekdays.tue": "Вт",
|
||||||
"weekdays.wed": "Ср",
|
"weekdays.wed": "Ср",
|
||||||
@@ -100,6 +122,7 @@ export const MESSAGES = {
|
|||||||
"event_type.other": "Другое",
|
"event_type.other": "Другое",
|
||||||
"duty.now_on_duty": "Сейчас дежурит",
|
"duty.now_on_duty": "Сейчас дежурит",
|
||||||
"duty.none_this_month": "В этом месяце дежурств нет.",
|
"duty.none_this_month": "В этом месяце дежурств нет.",
|
||||||
|
"duty.none_this_month_hint": "Дежурства появятся после загрузки расписания.",
|
||||||
"duty.today": "Сегодня",
|
"duty.today": "Сегодня",
|
||||||
"duty.until": "до {time}",
|
"duty.until": "до {time}",
|
||||||
"hint.from": "с",
|
"hint.from": "с",
|
||||||
@@ -107,35 +130,63 @@ export const MESSAGES = {
|
|||||||
"hint.duty_title": "Дежурство:",
|
"hint.duty_title": "Дежурство:",
|
||||||
"hint.events": "События:",
|
"hint.events": "События:",
|
||||||
"day_detail.close": "Закрыть",
|
"day_detail.close": "Закрыть",
|
||||||
|
"day_detail.no_events": "В этот день нет дежурств и событий.",
|
||||||
"contact.label": "Контакт",
|
"contact.label": "Контакт",
|
||||||
"contact.show": "Контакты",
|
"contact.show": "Контакты",
|
||||||
"contact.back": "Назад",
|
"contact.back": "Назад",
|
||||||
"contact.phone": "Телефон",
|
"contact.phone": "Телефон",
|
||||||
"contact.telegram": "Telegram",
|
"contact.telegram": "Telegram",
|
||||||
|
"contact.aria_call": "Позвонить {name}",
|
||||||
|
"contact.aria_telegram": "Написать {name} в Telegram",
|
||||||
"current_duty.title": "Сейчас дежурит",
|
"current_duty.title": "Сейчас дежурит",
|
||||||
"current_duty.no_duty": "Сейчас никто не дежурит",
|
"current_duty.no_duty": "Сейчас никто не дежурит",
|
||||||
"current_duty.shift": "Смена",
|
"current_duty.shift": "Смена",
|
||||||
|
"current_duty.shift_tz": "Смена ({tz}):",
|
||||||
|
"current_duty.shift_local": "Смена (ваше время):",
|
||||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||||
"current_duty.back": "Назад к календарю"
|
"current_duty.ends_at": "До конца смены в {time}",
|
||||||
}
|
"current_duty.back": "Назад к календарю",
|
||||||
|
"current_duty.close": "Закрыть",
|
||||||
|
"current_duty.contact_info_not_set": "Контактные данные не указаны",
|
||||||
|
"error_boundary.message": "Что-то пошло не так.",
|
||||||
|
"error_boundary.description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",
|
||||||
|
"error_boundary.reload": "Обновить",
|
||||||
|
"not_found.title": "Страница не найдена",
|
||||||
|
"not_found.description": "Запрашиваемая страница не существует.",
|
||||||
|
"not_found.open_calendar": "Открыть календарь",
|
||||||
|
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MONTH_KEYS = [
|
const MONTH_KEYS = [
|
||||||
"month.jan", "month.feb", "month.mar", "month.apr", "month.may", "month.jun",
|
"month.jan",
|
||||||
"month.jul", "month.aug", "month.sep", "month.oct", "month.nov", "month.dec"
|
"month.feb",
|
||||||
|
"month.mar",
|
||||||
|
"month.apr",
|
||||||
|
"month.may",
|
||||||
|
"month.jun",
|
||||||
|
"month.jul",
|
||||||
|
"month.aug",
|
||||||
|
"month.sep",
|
||||||
|
"month.oct",
|
||||||
|
"month.nov",
|
||||||
|
"month.dec",
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEEKDAY_KEYS = [
|
const WEEKDAY_KEYS = [
|
||||||
"weekdays.mon", "weekdays.tue", "weekdays.wed", "weekdays.thu",
|
"weekdays.mon",
|
||||||
"weekdays.fri", "weekdays.sat", "weekdays.sun"
|
"weekdays.tue",
|
||||||
|
"weekdays.wed",
|
||||||
|
"weekdays.thu",
|
||||||
|
"weekdays.fri",
|
||||||
|
"weekdays.sat",
|
||||||
|
"weekdays.sun",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalize language code to 'ru' or 'en'.
|
* Normalize language code to 'ru' or 'en'.
|
||||||
* @param {string} code - e.g. 'ru', 'en', 'uk'
|
|
||||||
* @returns {'ru'|'en'}
|
|
||||||
*/
|
*/
|
||||||
export function normalizeLang(code) {
|
export function normalizeLang(code: string | null | undefined): Lang {
|
||||||
if (!code || typeof code !== "string") return "en";
|
if (!code || typeof code !== "string") return "en";
|
||||||
const lower = code.toLowerCase();
|
const lower = code.toLowerCase();
|
||||||
if (lower.startsWith("ru")) return "ru";
|
if (lower.startsWith("ru")) return "ru";
|
||||||
@@ -145,71 +196,65 @@ export function normalizeLang(code) {
|
|||||||
/**
|
/**
|
||||||
* Get application language from backend config (window.__DT_LANG).
|
* Get application language from backend config (window.__DT_LANG).
|
||||||
* Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid.
|
* Set by /app/config.js from DEFAULT_LANGUAGE. Fallback to 'en' if missing or invalid.
|
||||||
* @returns {'ru'|'en'}
|
* Only valid in browser; returns 'en' when window is undefined (SSR).
|
||||||
*/
|
*/
|
||||||
export function getLang() {
|
export function getLang(): Lang {
|
||||||
|
if (typeof window === "undefined") return "en";
|
||||||
const raw =
|
const raw =
|
||||||
typeof window !== "undefined" && window.__DT_LANG != null
|
(window as unknown as { __DT_LANG?: string }).__DT_LANG != null
|
||||||
? String(window.__DT_LANG)
|
? String((window as unknown as { __DT_LANG?: string }).__DT_LANG)
|
||||||
: "";
|
: "";
|
||||||
const lang = normalizeLang(raw);
|
const lang = normalizeLang(raw);
|
||||||
return lang === "ru" ? "ru" : "en";
|
return lang === "ru" ? "ru" : "en";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function tEventType(lang: Lang, key: string): string {
|
||||||
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
|
const dict = MESSAGES[lang] ?? MESSAGES.en;
|
||||||
* @param {'ru'|'en'} lang
|
|
||||||
* @param {string} key - e.g. 'app.title', 'duty.until'
|
|
||||||
* @param {Record<string, string>} [params] - e.g. { time: '14:00' }
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Resolve event_type.* key with fallback: unknown types use event_type.duty or event_type.other.
|
|
||||||
* @param {'ru'|'en'} lang
|
|
||||||
* @param {string} key
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
function tEventType(lang, key) {
|
|
||||||
const dict = MESSAGES[lang] || MESSAGES.en;
|
|
||||||
const enDict = MESSAGES.en;
|
const enDict = MESSAGES.en;
|
||||||
let s = dict[key];
|
let s = dict[key];
|
||||||
if (s === undefined) s = enDict[key];
|
if (s === undefined) s = enDict[key];
|
||||||
if (s === undefined) {
|
if (s === undefined) {
|
||||||
s = dict["event_type.other"] || enDict["event_type.other"] || enDict["event_type.duty"] || key;
|
s =
|
||||||
|
dict["event_type.other"] ??
|
||||||
|
enDict["event_type.other"] ??
|
||||||
|
enDict["event_type.duty"] ??
|
||||||
|
key;
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function t(lang, key, params = {}) {
|
/**
|
||||||
const dict = MESSAGES[lang] || MESSAGES.en;
|
* Get translated string; fallback to en if key missing in lang. Supports {placeholder}.
|
||||||
|
*/
|
||||||
|
export function translate(
|
||||||
|
lang: Lang,
|
||||||
|
key: string,
|
||||||
|
params: Record<string, string> = {}
|
||||||
|
): string {
|
||||||
|
const dict = MESSAGES[lang] ?? MESSAGES.en;
|
||||||
let s = dict[key];
|
let s = dict[key];
|
||||||
if (s === undefined) s = MESSAGES.en[key];
|
if (s === undefined) s = MESSAGES.en[key];
|
||||||
if (s === undefined && key.startsWith("event_type.")) {
|
if (s === undefined && key.startsWith("event_type.")) {
|
||||||
return tEventType(lang, key);
|
return tEventType(lang, key);
|
||||||
}
|
}
|
||||||
if (s === undefined) return key;
|
if (s === undefined) return key;
|
||||||
Object.keys(params).forEach((k) => {
|
for (const k of Object.keys(params)) {
|
||||||
s = s.replace(new RegExp("\\{" + k + "\\}", "g"), params[k]);
|
s = s.split(`{${k}}`).join(params[k]);
|
||||||
});
|
}
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get month name by 0-based index.
|
* Get month name by 0-based index (0–11).
|
||||||
* @param {'ru'|'en'} lang
|
|
||||||
* @param {number} month - 0–11
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
export function monthName(lang, month) {
|
export function monthName(lang: Lang, month: number): string {
|
||||||
const key = MONTH_KEYS[month];
|
const key = MONTH_KEYS[month];
|
||||||
return key ? t(lang, key) : "";
|
return key ? translate(lang, key) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get weekday short labels (Mon–Sun order) for given lang.
|
* Get weekday short labels (Mon–Sun order) for given lang.
|
||||||
* @param {'ru'|'en'} lang
|
|
||||||
* @returns {string[]}
|
|
||||||
*/
|
*/
|
||||||
export function weekdayLabels(lang) {
|
export function weekdayLabels(lang: Lang): string[] {
|
||||||
return WEEKDAY_KEYS.map((k) => t(lang, k));
|
return WEEKDAY_KEYS.map((k) => translate(lang, k));
|
||||||
}
|
}
|
||||||
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 FETCH_TIMEOUT_MS = 15000;
|
||||||
export const RETRY_DELAY_MS = 800;
|
export const RETRY_DELAY_MS = 800;
|
||||||
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
||||||
|
export const RETRY_AFTER_ERROR_MS = 800;
|
||||||
|
export const MAX_GENERAL_RETRIES = 2;
|
||||||
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,
|
* Unit tests for date-utils. Ported from webapp/js/dateUtils.test.js.
|
||||||
* dutyOverlapsLocalRange, getMonday, formatHHMM.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
@@ -14,7 +13,7 @@ import {
|
|||||||
lastDayOfMonth,
|
lastDayOfMonth,
|
||||||
formatDateKey,
|
formatDateKey,
|
||||||
dateKeyToDDMM,
|
dateKeyToDDMM,
|
||||||
} from "./dateUtils.js";
|
} from "./date-utils";
|
||||||
|
|
||||||
describe("localDateString", () => {
|
describe("localDateString", () => {
|
||||||
it("formats date as YYYY-MM-DD", () => {
|
it("formats date as YYYY-MM-DD", () => {
|
||||||
@@ -139,16 +138,21 @@ describe("formatHHMM", () => {
|
|||||||
const result = formatHHMM(s);
|
const result = formatHHMM(s);
|
||||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
const expected = (d.getHours() < 10 ? "0" : "") + d.getHours() + ":" + (d.getMinutes() < 10 ? "0" : "") + d.getMinutes();
|
const expected =
|
||||||
|
(d.getHours() < 10 ? "0" : "") +
|
||||||
|
d.getHours() +
|
||||||
|
":" +
|
||||||
|
(d.getMinutes() < 10 ? "0" : "") +
|
||||||
|
d.getMinutes();
|
||||||
expect(result).toBe(expected);
|
expect(result).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty string for null", () => {
|
it("returns empty string for null", () => {
|
||||||
expect(formatHHMM(null)).toBe("");
|
expect(formatHHMM(null as unknown as string)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty string for undefined", () => {
|
it("returns empty string for undefined", () => {
|
||||||
expect(formatHHMM(undefined)).toBe("");
|
expect(formatHHMM(undefined as unknown as string)).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty string for empty string", () => {
|
it("returns empty string for empty string", () => {
|
||||||
@@ -227,4 +231,10 @@ describe("dateKeyToDDMM", () => {
|
|||||||
it("handles single-digit day and month", () => {
|
it("handles single-digit day and month", () => {
|
||||||
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
|
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("returns key unchanged when format is not YYYY-MM-DD", () => {
|
||||||
|
expect(dateKeyToDDMM("short")).toBe("short");
|
||||||
|
expect(dateKeyToDDMM("2025/02/25")).toBe("2025/02/25");
|
||||||
|
expect(dateKeyToDDMM("20250225")).toBe("20250225");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,26 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Date/time helpers for calendar and duty display.
|
* Date/time helpers for calendar and duty display.
|
||||||
|
* Ported from webapp/js/dateUtils.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* YYYY-MM-DD in local time (for calendar keys, "today", request range).
|
* YYYY-MM-DD in local time (for calendar keys, "today", request range).
|
||||||
* @param {Date} d - Date
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
*/
|
||||||
export function localDateString(d) {
|
export function localDateString(d: Date): string {
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
return y + "-" + m + "-" + day;
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Duty-like object with start_at and end_at (UTC ISO strings). */
|
||||||
|
export interface DutyLike {
|
||||||
|
start_at: string;
|
||||||
|
end_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD.
|
* True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD.
|
||||||
* @param {object} d - Duty with start_at, end_at
|
|
||||||
* @param {string} dateKey - YYYY-MM-DD
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
export function dutyOverlapsLocalDay(d, dateKey) {
|
export function dutyOverlapsLocalDay(d: DutyLike, dateKey: string): boolean {
|
||||||
const [y, m, day] = dateKey.split("-").map(Number);
|
const [y, m, day] = dateKey.split("-").map(Number);
|
||||||
const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0);
|
const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0);
|
||||||
const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999);
|
const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999);
|
||||||
@@ -31,12 +33,12 @@ export function dutyOverlapsLocalDay(d, dateKey) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive).
|
* True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive).
|
||||||
* @param {object} d - Duty with start_at, end_at
|
|
||||||
* @param {string} firstKey - YYYY-MM-DD (first day of range)
|
|
||||||
* @param {string} lastKey - YYYY-MM-DD (last day of range)
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
*/
|
||||||
export function dutyOverlapsLocalRange(d, firstKey, lastKey) {
|
export function dutyOverlapsLocalRange(
|
||||||
|
d: DutyLike,
|
||||||
|
firstKey: string,
|
||||||
|
lastKey: string
|
||||||
|
): boolean {
|
||||||
const [y1, m1, day1] = firstKey.split("-").map(Number);
|
const [y1, m1, day1] = firstKey.split("-").map(Number);
|
||||||
const [y2, m2, day2] = lastKey.split("-").map(Number);
|
const [y2, m2, day2] = lastKey.split("-").map(Number);
|
||||||
const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0);
|
const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0);
|
||||||
@@ -46,18 +48,16 @@ export function dutyOverlapsLocalRange(d, firstKey, lastKey) {
|
|||||||
return end > rangeStart && start < rangeEnd;
|
return end > rangeStart && start < rangeEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {Date} d - Date */
|
export function firstDayOfMonth(d: Date): Date {
|
||||||
export function firstDayOfMonth(d) {
|
|
||||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {Date} d - Date */
|
export function lastDayOfMonth(d: Date): Date {
|
||||||
export function lastDayOfMonth(d) {
|
|
||||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param {Date} d - Date (returns Monday of that week) */
|
/** Returns Monday of the week for the given date. */
|
||||||
export function getMonday(d) {
|
export function getMonday(d: Date): Date {
|
||||||
const day = d.getDay();
|
const day = d.getDay();
|
||||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||||
return new Date(d.getFullYear(), d.getMonth(), diff);
|
return new Date(d.getFullYear(), d.getMonth(), diff);
|
||||||
@@ -65,31 +65,27 @@ export function getMonday(d) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Format UTC date from ISO string as DD.MM for display.
|
* Format UTC date from ISO string as DD.MM for display.
|
||||||
* @param {string} isoDateStr - ISO date string
|
|
||||||
* @returns {string} DD.MM
|
|
||||||
*/
|
*/
|
||||||
export function formatDateKey(isoDateStr) {
|
export function formatDateKey(isoDateStr: string): string {
|
||||||
const d = new Date(isoDateStr);
|
const d = new Date(isoDateStr);
|
||||||
const day = String(d.getDate()).padStart(2, "0");
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
return day + "." + month;
|
return `${day}.${month}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format YYYY-MM-DD as DD.MM for list header.
|
* Format YYYY-MM-DD as DD.MM for list header.
|
||||||
* @param {string} key - YYYY-MM-DD
|
* Returns key unchanged if it does not match YYYY-MM-DD format.
|
||||||
* @returns {string} DD.MM
|
|
||||||
*/
|
*/
|
||||||
export function dateKeyToDDMM(key) {
|
export function dateKeyToDDMM(key: string): string {
|
||||||
|
if (key.length < 10 || key[4] !== "-" || key[7] !== "-") return key;
|
||||||
return key.slice(8, 10) + "." + key.slice(5, 7);
|
return key.slice(8, 10) + "." + key.slice(5, 7);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format ISO string as HH:MM (local).
|
* Format ISO string as HH:MM (local).
|
||||||
* @param {string} isoStr - ISO date string
|
|
||||||
* @returns {string} HH:MM or ""
|
|
||||||
*/
|
*/
|
||||||
export function formatHHMM(isoStr) {
|
export function formatHHMM(isoStr: string): string {
|
||||||
if (!isoStr) return "";
|
if (!isoStr) return "";
|
||||||
const d = new Date(isoStr);
|
const d = new Date(isoStr);
|
||||||
const h = d.getHours();
|
const h = d.getHours();
|
||||||
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.
|
* Unit tests for logger: level filtering and console delegation.
|
||||||
|
* Ported from webapp/js/logger.test.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
describe("logger", () => {
|
describe("logger", () => {
|
||||||
const origWindow = globalThis.window;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||||
@@ -18,13 +17,13 @@ describe("logger", () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (globalThis.window) {
|
if (globalThis.window) {
|
||||||
delete globalThis.window.__DT_LOG_LEVEL;
|
delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function setLevel(level) {
|
function setLevel(level: string) {
|
||||||
if (!globalThis.window) globalThis.window = {};
|
if (!globalThis.window) (globalThis as unknown as { window: object }).window = {};
|
||||||
globalThis.window.__DT_LOG_LEVEL = level;
|
(globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL = level;
|
||||||
}
|
}
|
||||||
|
|
||||||
it("at level info does not call console.debug", () => {
|
it("at level info does not call console.debug", () => {
|
||||||
@@ -79,7 +78,7 @@ describe("logger", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to info when __DT_LOG_LEVEL is missing", () => {
|
it("defaults to info when __DT_LOG_LEVEL is missing", () => {
|
||||||
if (globalThis.window) delete globalThis.window.__DT_LOG_LEVEL;
|
if (globalThis.window) delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
|
||||||
logger.debug("no");
|
logger.debug("no");
|
||||||
expect(console.debug).not.toHaveBeenCalled();
|
expect(console.debug).not.toHaveBeenCalled();
|
||||||
logger.info("yes");
|
logger.info("yes");
|
||||||
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