Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68b1884b73 | |||
| fb786c4c3a | |||
| 07e22079ee | |||
| 13aba85e28 | |||
| 8ad8dffd0a | |||
| 6d087d1b26 | |||
| 94545dc8c3 | |||
| 33359f589a | |||
| 3244fbe505 | |||
| d99912b080 | |||
| 6adec62b5f | |||
| a8d4afb101 | |||
| 106e42a81d | |||
| 3c4c28a1ac | |||
| 119661628e | |||
| 336e6d48c5 |
55
.cursor/skills/project-release/SKILL.md
Normal file
55
.cursor/skills/project-release/SKILL.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
name: project-release
|
||||||
|
description: Performs a project release by updating CHANGELOG for the new version, committing all changes, and pushing a version tag. Use when the user asks to release, cut a release, publish a version, or to update changelog and push a tag.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Release
|
||||||
|
|
||||||
|
Release workflow for duty-teller: update changelog, commit, tag, and push. Triggers Gitea Actions (Docker build) on `v*` tags.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Decide the **new version** (e.g. `2.1.0`). Use [Semantic Versioning](https://semver.org/).
|
||||||
|
- Ensure `CHANGELOG.md` has entries under `## [Unreleased]` (or add a short note like "No changes" if intentional).
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Update CHANGELOG.md
|
||||||
|
|
||||||
|
- Replace the `## [Unreleased]` section with a dated release section:
|
||||||
|
- `## [X.Y.Z] - YYYY-MM-DD` (use today's date in `YYYY-MM-DD`).
|
||||||
|
- Leave a new empty `## [Unreleased]` section after it (for future edits).
|
||||||
|
- At the bottom of the file, add the comparison link for the new version:
|
||||||
|
- `[X.Y.Z]: https://github.com/your-org/duty-teller/releases/tag/vX.Y.Z`
|
||||||
|
- (Replace `your-org/duty-teller` with the real repo URL if different.)
|
||||||
|
- Update the `[Unreleased]` link to compare against this release, e.g.:
|
||||||
|
- `[Unreleased]: https://github.com/your-org/duty-teller/compare/vX.Y.Z...HEAD`
|
||||||
|
|
||||||
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); keep existing subsections (Added, Changed, Security, etc.) under the new version.
|
||||||
|
|
||||||
|
### 2. Bump version in pyproject.toml (optional)
|
||||||
|
|
||||||
|
- Set `version = "X.Y.Z"` in `[project]` so it matches the release. Skip if the project does not sync version here.
|
||||||
|
|
||||||
|
### 3. Commit and tag
|
||||||
|
|
||||||
|
- Stage all changes: `git add -A`
|
||||||
|
- Commit with Conventional Commits: `git commit -m "chore(release): vX.Y.Z"`
|
||||||
|
- Create annotated tag: `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||||
|
- Push branch: `git push origin main` (or current branch)
|
||||||
|
- Push tag: `git push origin vX.Y.Z`
|
||||||
|
|
||||||
|
Pushing the `v*` tag triggers `.gitea/workflows/docker-build.yml` (Docker build and release).
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] CHANGELOG: `[Unreleased]` → `[X.Y.Z] - YYYY-MM-DD`, new empty `[Unreleased]`, links at bottom updated
|
||||||
|
- [ ] pyproject.toml version set to X.Y.Z (if used)
|
||||||
|
- [ ] `git add -A` && `git commit -m "chore(release): vX.Y.Z"`
|
||||||
|
- [ ] `git tag -a vX.Y.Z -m "Release vX.Y.Z"`
|
||||||
|
- [ ] `git push origin main` && `git push origin vX.Y.Z`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not push tags from unreleased or uncommitted changelog.
|
||||||
|
- If the repo URL in CHANGELOG links is a placeholder, keep it or ask the user for the correct base URL.
|
||||||
60
.cursor/skills/run-tests/SKILL.md
Normal file
60
.cursor/skills/run-tests/SKILL.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: run-tests
|
||||||
|
description: Runs backend (pytest) and frontend (Vitest) tests for the duty-teller project. Use when the user asks to run tests, verify changes, or run pytest/vitest.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
- User asks to "run tests", "run the test suite", or "verify tests pass".
|
||||||
|
- After making code changes and user wants to confirm nothing is broken.
|
||||||
|
- User explicitly asks for backend tests (pytest) or frontend tests (vitest/npm test).
|
||||||
|
|
||||||
|
## Backend tests (Python)
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
If imports fail, set `PYTHONPATH`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=. pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
- Config: `pyproject.toml` → `[tool.pytest.ini_options]` (coverage on `duty_teller`, 80% gate, asyncio mode).
|
||||||
|
- Tests live in `tests/`.
|
||||||
|
|
||||||
|
## Frontend tests (Next.js / Vitest)
|
||||||
|
|
||||||
|
From the **repository root**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webapp-next && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
- Runner: Vitest (`vitest run`); env: jsdom; React Testing Library.
|
||||||
|
- Config: `webapp-next/vitest.config.ts`; setup: `webapp-next/src/test/setup.ts`.
|
||||||
|
|
||||||
|
## Running both
|
||||||
|
|
||||||
|
To run backend and frontend tests in sequence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest && (cd webapp-next && npm test)
|
||||||
|
```
|
||||||
|
|
||||||
|
If the user did not specify "backend only" or "frontend only", run both and report results for each.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- **Single file or dir:** `pytest path/to/test_file.py` or `pytest path/to/test_dir/`. For frontend, use Vitest’s path args as per its docs (e.g. under `webapp-next/`).
|
||||||
|
- **Verbosity:** Use `pytest -v` if the user wants more detail.
|
||||||
|
|
||||||
|
## Failures
|
||||||
|
|
||||||
|
- Do not send raw exception strings from tests to the user; summarize failures and point to failing test names/locations.
|
||||||
|
- If pytest fails with import errors, suggest `PYTHONPATH=. pytest` and ensure the venv is activated and dev deps are installed (`pip install -r requirements-dev.txt` or `pip install -e ".[dev]"`).
|
||||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.0.6] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.0.4] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.0.3] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
|
## [2.0.2] - 2025-03-04
|
||||||
|
|
||||||
|
(No changes documented; release for version sync.)
|
||||||
|
|
||||||
## [2.0.0] - 2026-03-03
|
## [2.0.0] - 2026-03-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -44,5 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Input validation and initData hash verification for Miniapp access.
|
- Input validation and initData hash verification for Miniapp access.
|
||||||
- Optional CORS and init_data_max_age; use env for secrets.
|
- Optional CORS and init_data_max_age; use env for secrets.
|
||||||
|
|
||||||
|
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.0.6...HEAD
|
||||||
|
[2.0.6]: https://github.com/your-org/duty-teller/releases/tag/v2.0.6 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
[2.0.4]: https://github.com/your-org/duty-teller/releases/tag/v2.0.4 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
[2.0.3]: https://github.com/your-org/duty-teller/releases/tag/v2.0.3 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
[2.0.2]: https://github.com/your-org/duty-teller/releases/tag/v2.0.2 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
[2.0.0]: https://github.com/your-org/duty-teller/releases/tag/v2.0.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
[0.1.0]: https://github.com/your-org/duty-teller/releases/tag/v0.1.0 <!-- placeholder: set to your repo URL when publishing -->
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ from duty_teller.utils.http_client import safe_urlopen
|
|||||||
_HTTP_STARTUP_WAIT_SEC = 3
|
_HTTP_STARTUP_WAIT_SEC = 3
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_init(application) -> None:
|
||||||
|
"""Run startup tasks: restore group pin jobs, then resolve bot username."""
|
||||||
|
await group_duty_pin.restore_group_pin_jobs(application)
|
||||||
|
await _resolve_bot_username(application)
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_bot_username(application) -> None:
|
async def _resolve_bot_username(application) -> None:
|
||||||
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
"""If BOT_USERNAME is not set from env, resolve it via get_me()."""
|
||||||
if not config.BOT_USERNAME:
|
if not config.BOT_USERNAME:
|
||||||
@@ -98,13 +104,7 @@ def main() -> None:
|
|||||||
require_bot_token()
|
require_bot_token()
|
||||||
# Optional: set bot menu button to open the Miniapp. Uncomment to enable:
|
# Optional: set bot menu button to open the Miniapp. Uncomment to enable:
|
||||||
# _set_default_menu_button_webapp()
|
# _set_default_menu_button_webapp()
|
||||||
app = (
|
app = ApplicationBuilder().token(config.BOT_TOKEN).post_init(_post_init).build()
|
||||||
ApplicationBuilder()
|
|
||||||
.token(config.BOT_TOKEN)
|
|
||||||
.post_init(group_duty_pin.restore_group_pin_jobs)
|
|
||||||
.post_init(_resolve_bot_username)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
register_handlers(app)
|
register_handlers(app)
|
||||||
|
|
||||||
from duty_teller.api.app import app as web_app
|
from duty_teller.api.app import app as web_app
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "duty-teller"
|
name = "duty-teller"
|
||||||
version = "0.1.0"
|
version = "2.0.6"
|
||||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ html::-webkit-scrollbar {
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
padding-bottom: env(safe-area-inset-bottom, 12px);
|
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 12px));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,9 +261,25 @@ html::-webkit-scrollbar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Android low-performance devices: minimize animations (Telegram User-Agent). */
|
||||||
|
[data-perf="low"] *,
|
||||||
|
[data-perf="low"] *::before,
|
||||||
|
[data-perf="low"] *::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Safe area for Telegram Mini App (notch / status bar). */
|
/* Safe area for Telegram Mini App (notch / status bar). */
|
||||||
.pt-safe {
|
.pt-safe {
|
||||||
padding-top: env(safe-area-inset-top, 0);
|
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content safe area: top/bottom only to avoid overlap with Telegram header/bottom bar (Bot API 8.0+).
|
||||||
|
Horizontal padding is left to layout classes (e.g. px-3) so indents are preserved when viewport vars are 0. */
|
||||||
|
.content-safe {
|
||||||
|
padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0));
|
||||||
|
padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
/* Sticky calendar header: shadow when scrolled (useStickyScroll). */
|
||||||
@@ -305,7 +321,7 @@ html::-webkit-scrollbar {
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: var(--tg-viewport-stable-height, 100vh);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
|||||||
@@ -4,17 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
|
||||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
useTelegramAuth: () => ({
|
useTelegramAuth: vi.fn(),
|
||||||
initDataRaw: "test-init",
|
|
||||||
startParam: undefined,
|
|
||||||
isLocalhost: true,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-month-data", () => ({
|
vi.mock("@/hooks/use-month-data", () => ({
|
||||||
@@ -26,6 +23,11 @@ vi.mock("@/hooks/use-month-data", () => ({
|
|||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAppStore();
|
resetAppStore();
|
||||||
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
initDataRaw: "test-init",
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders calendar and header when store has default state", async () => {
|
it("renders calendar and header when store has default state", async () => {
|
||||||
@@ -51,4 +53,27 @@ describe("Page", () => {
|
|||||||
expect(document.title).toBe("Календарь дежурств");
|
expect(document.title).toBe("Календарь дежурств");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||||
|
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||||
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
initDataRaw: undefined,
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: false,
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<Page />);
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 2000 })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Reload|Обновить/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("grid", { name: "Calendar" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, type AppState } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { useAppInit } from "@/hooks/use-app-init";
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||||
import { CalendarPage } from "@/components/CalendarPage";
|
import { CalendarPage } from "@/components/CalendarPage";
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ export default function Home() {
|
|||||||
|
|
||||||
useAppInit({ isAllowed, startParam });
|
useAppInit({ isAllowed, startParam });
|
||||||
|
|
||||||
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
|
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||||
useAppStore(
|
useAppStore(
|
||||||
useShallow((s) => ({
|
useShallow((s: AppState) => ({
|
||||||
|
accessDenied: s.accessDenied,
|
||||||
currentView: s.currentView,
|
currentView: s.currentView,
|
||||||
setCurrentView: s.setCurrentView,
|
setCurrentView: s.setCurrentView,
|
||||||
setSelectedDay: s.setSelectedDay,
|
setSelectedDay: s.setSelectedDay,
|
||||||
@@ -45,23 +47,24 @@ export default function Home() {
|
|||||||
setSelectedDay(null);
|
setSelectedDay(null);
|
||||||
}, [setCurrentView, setSelectedDay]);
|
}, [setCurrentView, setSelectedDay]);
|
||||||
|
|
||||||
const content =
|
const content = accessDenied ? (
|
||||||
currentView === "currentDuty" ? (
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
<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">
|
) : currentView === "currentDuty" ? (
|
||||||
<CurrentDutyView
|
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
onBack={handleBackFromCurrentDuty}
|
<CurrentDutyView
|
||||||
openedFromPin={startParam === "duty"}
|
onBack={handleBackFromCurrentDuty}
|
||||||
/>
|
openedFromPin={startParam === "duty"}
|
||||||
</div>
|
/>
|
||||||
) : (
|
</div>
|
||||||
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
) : (
|
||||||
);
|
<CalendarPage isAllowed={isAllowed} initDataRaw={initDataRaw} />
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="min-h-[var(--tg-viewport-stable-height,100vh)]"
|
||||||
style={{
|
style={{
|
||||||
visibility: appContentReady ? "visible" : "hidden",
|
visibility: appContentReady ? "visible" : "hidden",
|
||||||
minHeight: "100vh",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
|||||||
import { DutyList } from "@/components/duty/DutyList";
|
import { DutyList } from "@/components/duty/DutyList";
|
||||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||||
import { ErrorState } from "@/components/states/ErrorState";
|
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). */
|
/** 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;
|
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||||
@@ -51,7 +50,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
accessDenied,
|
accessDenied,
|
||||||
accessDeniedDetail,
|
|
||||||
duties,
|
duties,
|
||||||
calendarEvents,
|
calendarEvents,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
@@ -67,7 +65,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
loading: s.loading,
|
loading: s.loading,
|
||||||
error: s.error,
|
error: s.error,
|
||||||
accessDenied: s.accessDenied,
|
accessDenied: s.accessDenied,
|
||||||
accessDeniedDetail: s.accessDeniedDetail,
|
|
||||||
duties: s.duties,
|
duties: s.duties,
|
||||||
calendarEvents: s.calendarEvents,
|
calendarEvents: s.calendarEvents,
|
||||||
selectedDay: s.selectedDay,
|
selectedDay: s.selectedDay,
|
||||||
@@ -136,7 +133,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
}, [loading, accessDenied, setAppContentReady]);
|
}, [loading, accessDenied, setAppContentReady]);
|
||||||
|
|
||||||
return (
|
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 className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
|
||||||
<div
|
<div
|
||||||
ref={calendarStickyRef}
|
ref={calendarStickyRef}
|
||||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||||
@@ -155,13 +152,10 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{accessDenied && (
|
{error && (
|
||||||
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
|
||||||
)}
|
|
||||||
{!accessDenied && error && (
|
|
||||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||||
)}
|
)}
|
||||||
{!accessDenied && !error && (
|
{!error && (
|
||||||
<DutyList
|
<DutyList
|
||||||
scrollMarginTop={stickyBlockHeight}
|
scrollMarginTop={stickyBlockHeight}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
|||||||
@@ -49,13 +49,18 @@ export function CalendarHeader({
|
|||||||
>
|
>
|
||||||
<ChevronLeftIcon className="size-5" aria-hidden />
|
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex flex-col items-center gap-0.5">
|
<div className="flex min-h-[2rem] flex-col items-center justify-center gap-0">
|
||||||
<h1
|
<h1
|
||||||
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
|
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
{monthName(monthIndex)} {year}
|
<span className="text-xs font-normal leading-none text-muted">
|
||||||
|
{year}
|
||||||
|
</span>
|
||||||
|
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
||||||
|
{monthName(monthIndex)}
|
||||||
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -57,6 +57,18 @@ describe("CurrentDutyView", () => {
|
|||||||
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows Open calendar button in no-duty view and it calls onBack", async () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
|
||||||
|
const openCalendarBtn = screen.getByRole("button", {
|
||||||
|
name: /Open calendar|Открыть календарь/i,
|
||||||
|
});
|
||||||
|
expect(openCalendarBtn).toBeInTheDocument();
|
||||||
|
fireEvent.click(openCalendarBtn);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows contact info not set when duty has no phone or username", async () => {
|
it("shows contact info not set when duty has no phone or username", async () => {
|
||||||
const { fetchDuties } = await import("@/lib/api");
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -81,4 +93,59 @@ describe("CurrentDutyView", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows ends_at line when duty is active", async () => {
|
||||||
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
const end = new Date(now.getTime() + 2 * 60 * 60 * 1000);
|
||||||
|
const duty = {
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
start_at: start.toISOString(),
|
||||||
|
end_at: end.toISOString(),
|
||||||
|
event_type: "duty" as const,
|
||||||
|
full_name: "Test User",
|
||||||
|
phone: null,
|
||||||
|
username: null,
|
||||||
|
};
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([duty]);
|
||||||
|
render(<CurrentDutyView onBack={vi.fn()} />);
|
||||||
|
await screen.findByText("Test User", {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Until end of shift at|До конца смены в/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error state shows Retry as first button", async () => {
|
||||||
|
const { fetchDuties } = await import("@/lib/api");
|
||||||
|
vi.mocked(fetchDuties).mockRejectedValue(new Error("Network error"));
|
||||||
|
render(<CurrentDutyView onBack={vi.fn()} />);
|
||||||
|
await screen.findByText(/Could not load|Не удалось загрузить/i, {}, { timeout: 3000 });
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i);
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 shows AccessDeniedScreen with Back button and no Retry", async () => {
|
||||||
|
const { fetchDuties, AccessDeniedError } = await import("@/lib/api");
|
||||||
|
vi.mocked(fetchDuties).mockRejectedValue(
|
||||||
|
new AccessDeniedError("ACCESS_DENIED", "Custom 403 message")
|
||||||
|
);
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /Retry|Повторить/i })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i }));
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
formatHHMM,
|
formatHHMM,
|
||||||
} from "@/lib/date-utils";
|
} from "@/lib/date-utils";
|
||||||
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
import { ContactLinks } from "@/components/contact/ContactLinks";
|
import { ContactLinks } from "@/components/contact/ContactLinks";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +30,8 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import type { DutyWithUser } from "@/types";
|
import type { DutyWithUser } from "@/types";
|
||||||
|
|
||||||
export interface CurrentDutyViewProps {
|
export interface CurrentDutyViewProps {
|
||||||
@@ -38,7 +41,7 @@ export interface CurrentDutyViewProps {
|
|||||||
openedFromPin?: boolean;
|
openedFromPin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewState = "loading" | "error" | "ready";
|
type ViewState = "loading" | "error" | "accessDenied" | "ready";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||||
@@ -52,6 +55,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
const [state, setState] = useState<ViewState>("loading");
|
const [state, setState] = useState<ViewState>("loading");
|
||||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [accessDeniedDetail, setAccessDeniedDetail] = useState<string | null>(null);
|
||||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||||
|
|
||||||
const loadTodayDuties = useCallback(
|
const loadTodayDuties = useCallback(
|
||||||
@@ -73,14 +77,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
setState("error");
|
if (e instanceof AccessDeniedError) {
|
||||||
const msg =
|
setState("accessDenied");
|
||||||
e instanceof AccessDeniedError && e.serverDetail
|
setAccessDeniedDetail(e.serverDetail ?? null);
|
||||||
? e.serverDetail
|
setDuty(null);
|
||||||
: t("error_generic");
|
setRemaining(null);
|
||||||
setErrorMessage(msg);
|
} else {
|
||||||
setDuty(null);
|
setState("error");
|
||||||
setRemaining(null);
|
setErrorMessage(t("error_generic"));
|
||||||
|
setDuty(null);
|
||||||
|
setRemaining(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[initDataRaw, lang, t]
|
[initDataRaw, lang, t]
|
||||||
@@ -139,10 +146,12 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
}, [onBack]);
|
}, [onBack]);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
|
triggerHapticLight();
|
||||||
onBack();
|
onBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
triggerHapticLight();
|
||||||
if (closeMiniApp.isAvailable()) {
|
if (closeMiniApp.isAvailable()) {
|
||||||
closeMiniApp();
|
closeMiniApp();
|
||||||
} else {
|
} else {
|
||||||
@@ -164,20 +173,50 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label={t("loading")}
|
aria-label={t("loading")}
|
||||||
>
|
>
|
||||||
<span
|
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
|
||||||
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
|
<CardContent className="flex flex-col gap-4 pt-6">
|
||||||
aria-hidden
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Skeleton className="size-2 shrink-0 rounded-full" />
|
||||||
<p className="text-muted-foreground m-0">{t("loading")}</p>
|
<Skeleton className="h-5 w-24 rounded-full" />
|
||||||
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
</div>
|
||||||
{primaryButtonLabel}
|
<div className="flex flex-col gap-2">
|
||||||
</Button>
|
<Skeleton className="h-7 w-48" />
|
||||||
|
<Skeleton className="h-4 w-full max-w-[200px]" />
|
||||||
|
<Skeleton className="h-4 w-full max-w-[280px]" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-14 w-full rounded-lg" />
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border/50 pt-4">
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
<Skeleton className="h-12 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
onClick={handlePrimaryAction}
|
||||||
|
aria-label={primaryButtonAriaLabel}
|
||||||
|
>
|
||||||
|
{primaryButtonLabel}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state === "accessDenied") {
|
||||||
|
return (
|
||||||
|
<AccessDeniedScreen
|
||||||
|
serverDetail={accessDeniedDetail}
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={handlePrimaryAction}
|
||||||
|
openedFromPin={openedFromPin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state === "error") {
|
if (state === "error") {
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
|
triggerHapticLight();
|
||||||
setState("loading");
|
setState("loading");
|
||||||
loadTodayDuties();
|
loadTodayDuties();
|
||||||
};
|
};
|
||||||
@@ -226,10 +265,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
{t("current_duty.no_duty")}
|
{t("current_duty.no_duty")}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
|
||||||
{primaryButtonLabel}
|
{primaryButtonLabel}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
aria-label={t("current_duty.open_calendar")}
|
||||||
|
>
|
||||||
|
{t("current_duty.open_calendar")}
|
||||||
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,11 +290,10 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
const endTime = formatHHMM(duty.end_at);
|
const endTime = formatHHMM(duty.end_at);
|
||||||
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
const shiftStr = `${startDDMM} ${startTime} — ${endDDMM} ${endTime}`;
|
||||||
const rem = remaining ?? getRemainingTime(duty.end_at);
|
const rem = remaining ?? getRemainingTime(duty.end_at);
|
||||||
const remainingStr = t("current_duty.remaining", {
|
const remainingValueStr = t("current_duty.remaining_value", {
|
||||||
hours: String(rem.hours),
|
hours: String(rem.hours),
|
||||||
minutes: String(rem.minutes),
|
minutes: String(rem.minutes),
|
||||||
});
|
});
|
||||||
const endsAtStr = t("current_duty.ends_at", { time: endTime });
|
|
||||||
|
|
||||||
const displayTz =
|
const displayTz =
|
||||||
typeof window !== "undefined" &&
|
typeof window !== "undefined" &&
|
||||||
@@ -268,33 +313,47 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
role="article"
|
role="article"
|
||||||
aria-labelledby="current-duty-title"
|
aria-labelledby="current-duty-title"
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader className="sr-only">
|
||||||
<CardTitle
|
<CardTitle id="current-duty-title">{t("current_duty.title")}</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>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-4 pt-6">
|
||||||
<p className="font-medium text-foreground" id="current-duty-name">
|
<span
|
||||||
{duty.full_name}
|
className="inline-flex w-fit items-center gap-2 rounded-full bg-duty/15 px-2.5 py-1 text-xs font-medium text-foreground"
|
||||||
</p>
|
aria-hidden
|
||||||
<p className="text-sm text-muted-foreground">
|
>
|
||||||
{shiftLabel} {shiftStr}
|
<span className="size-2 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none" />
|
||||||
</p>
|
{t("duty.now_on_duty")}
|
||||||
|
</span>
|
||||||
|
<section className="flex flex-col gap-2" aria-label={t("current_duty.shift")}>
|
||||||
|
<p
|
||||||
|
className="text-xl font-bold text-foreground leading-tight"
|
||||||
|
id="current-duty-name"
|
||||||
|
>
|
||||||
|
{duty.full_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground break-words">
|
||||||
|
{shiftLabel}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground break-words">
|
||||||
|
{shiftStr}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
<div
|
<div
|
||||||
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
|
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-atomic="true"
|
aria-atomic="true"
|
||||||
>
|
>
|
||||||
{remainingStr}
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("current_duty.remaining_label")}
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold text-foreground tabular-nums">
|
||||||
|
{remainingValueStr}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t("current_duty.ends_at", { time: formatHHMM(duty.end_at) })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
|
<section className="flex flex-col gap-2 border-t border-border/50 pt-4" aria-label={t("contact.label")}>
|
||||||
{hasContacts ? (
|
{hasContacts ? (
|
||||||
<ContactLinks
|
<ContactLinks
|
||||||
phone={duty.phone}
|
phone={duty.phone}
|
||||||
@@ -308,6 +367,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
{t("current_duty.contact_info_not_set")}
|
{t("current_duty.contact_info_not_set")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</section>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardFooter>
|
<CardFooter>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -207,8 +207,10 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
|||||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
overlayClassName="backdrop-blur-md"
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
onCloseAnimationEnd={handleClose}
|
onCloseAnimationEnd={handleClose}
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="relative px-4">
|
<div className="relative px-4">
|
||||||
{closeButton}
|
{closeButton}
|
||||||
|
|||||||
@@ -112,13 +112,21 @@ export function DayDetailContent({
|
|||||||
>
|
>
|
||||||
{t("event_type.duty")}
|
{t("event_type.duty")}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-1">
|
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||||
{dutyRows.map((r) => (
|
{dutyRows.map((r) => (
|
||||||
<li key={r.id}>
|
<li key={r.id}>
|
||||||
{r.timePrefix && (
|
<span
|
||||||
<span className="text-muted-foreground">{r.timePrefix} — </span>
|
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||||
)}
|
aria-hidden
|
||||||
<span className="font-semibold">{r.fullName}</span>
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-baseline gap-1">
|
||||||
|
{r.timePrefix && (
|
||||||
|
<span className="text-muted-foreground">{r.timePrefix} — </span>
|
||||||
|
)}
|
||||||
|
<span className="font-semibold">{r.fullName}</span>
|
||||||
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -136,9 +144,17 @@ export function DayDetailContent({
|
|||||||
>
|
>
|
||||||
{t("event_type.unavailable")}
|
{t("event_type.unavailable")}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||||
{uniqueUnavailable.map((name) => (
|
{uniqueUnavailable.map((name) => (
|
||||||
<li key={name}>{name}</li>
|
<li key={name}>
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -155,9 +171,17 @@ export function DayDetailContent({
|
|||||||
>
|
>
|
||||||
{t("event_type.vacation")}
|
{t("event_type.vacation")}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||||
{uniqueVacation.map((name) => (
|
{uniqueVacation.map((name) => (
|
||||||
<li key={name}>{name}</li>
|
<li key={name}>
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
@@ -174,9 +198,17 @@ export function DayDetailContent({
|
|||||||
>
|
>
|
||||||
{t("hint.events")}
|
{t("hint.events")}
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
|
<ul className="list-none pl-5 m-0 text-[0.9rem] leading-snug space-y-1 [&_li]:flex [&_li]:items-baseline [&_li]:gap-2">
|
||||||
{summaries.map((s) => (
|
{summaries.map((s) => (
|
||||||
<li key={String(s)}>{String(s)}</li>
|
<li key={String(s)}>
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground -ml-5 mr-0 shrink-0 select-none"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{String(s)}
|
||||||
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -6,15 +6,20 @@ import {
|
|||||||
mountMiniAppSync,
|
mountMiniAppSync,
|
||||||
mountThemeParamsSync,
|
mountThemeParamsSync,
|
||||||
bindThemeParamsCssVars,
|
bindThemeParamsCssVars,
|
||||||
|
mountViewport,
|
||||||
|
bindViewportCssVars,
|
||||||
|
unmountViewport,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||||
|
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wraps the app with Telegram Mini App SDK initialization.
|
* Wraps the app with Telegram Mini App SDK initialization.
|
||||||
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
|
||||||
* and mounts the mini app. Does not call ready() here — the app calls
|
* mounts the mini app, then mounts viewport and binds viewport CSS vars
|
||||||
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
|
* (--tg-viewport-stable-height, --tg-viewport-content-safe-area-inset-*, etc.).
|
||||||
* has finished loading, so Telegram keeps its native loading animation until then.
|
* Does not call ready() here — the app calls callMiniAppReadyOnce() from
|
||||||
|
* lib/telegram-ready when the first visible screen has finished loading.
|
||||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||||
*/
|
*/
|
||||||
@@ -39,7 +44,26 @@ export function TelegramProvider({
|
|||||||
mountMiniAppSync();
|
mountMiniAppSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanup;
|
applyAndroidPerformanceClass();
|
||||||
|
|
||||||
|
let unbindViewportCssVars: (() => void) | undefined;
|
||||||
|
if (mountViewport.isAvailable()) {
|
||||||
|
mountViewport()
|
||||||
|
.then(() => {
|
||||||
|
if (bindViewportCssVars.isAvailable()) {
|
||||||
|
unbindViewportCssVars = bindViewportCssVars();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Viewport not supported (e.g. not in Mini App); ignore.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unbindViewportCssVars?.();
|
||||||
|
unmountViewport();
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for AccessDeniedScreen: full-screen access denied, reload and back modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { AccessDeniedScreen } from "./AccessDeniedScreen";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("AccessDeniedScreen", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders translated access denied title and hint", () => {
|
||||||
|
render(<AccessDeniedScreen primaryAction="reload" />);
|
||||||
|
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows serverDetail when provided", () => {
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen primaryAction="reload" serverDetail="Custom 403 message" />
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload mode shows Reload button", () => {
|
||||||
|
render(<AccessDeniedScreen primaryAction="reload" />);
|
||||||
|
const button = screen.getByRole("button", { name: /Reload|Обновить/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
const reloadFn = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { ...window.location, reload: reloadFn },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(reloadFn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("back mode shows Back to calendar and calls onBack on click", () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={onBack}
|
||||||
|
openedFromPin={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: /Back to calendar|Назад к календарю/i,
|
||||||
|
});
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("back mode with openedFromPin shows Close button", () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={onBack}
|
||||||
|
openedFromPin={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /Close|Закрыть/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
86
webapp-next/src/components/states/AccessDeniedScreen.tsx
Normal file
86
webapp-next/src/components/states/AccessDeniedScreen.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Full-screen access denied view. Used when the user is not allowed (no initData / not localhost)
|
||||||
|
* or when API returns 403. Matches global-error and not-found layout: no extra chrome, one action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
|
||||||
|
export interface AccessDeniedScreenProps {
|
||||||
|
/** Optional detail from API 403 response, shown below the hint. */
|
||||||
|
serverDetail?: string | null;
|
||||||
|
/** Primary button: reload (main page) or back/close (deep link). */
|
||||||
|
primaryAction: "reload" | "back";
|
||||||
|
/** Called when primaryAction is "back" (e.g. Back to calendar or Close). */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** When true and primaryAction is "back", button label is "Close" instead of "Back to calendar". */
|
||||||
|
openedFromPin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen access denied: title, hint, optional server detail, single action button.
|
||||||
|
* Calls setAppContentReady(true) on mount so Telegram receives ready().
|
||||||
|
*/
|
||||||
|
export function AccessDeniedScreen({
|
||||||
|
serverDetail,
|
||||||
|
primaryAction,
|
||||||
|
onBack,
|
||||||
|
openedFromPin = false,
|
||||||
|
}: AccessDeniedScreenProps) {
|
||||||
|
const lang = getLang();
|
||||||
|
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppContentReady(true);
|
||||||
|
}, [setAppContentReady]);
|
||||||
|
|
||||||
|
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
triggerHapticLight();
|
||||||
|
if (primaryAction === "reload") {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onBack?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonLabel =
|
||||||
|
primaryAction === "reload"
|
||||||
|
? translate(lang, "access_denied.reload")
|
||||||
|
: openedFromPin
|
||||||
|
? translate(lang, "current_duty.close")
|
||||||
|
: translate(lang, "current_duty.back");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{translate(lang, "access_denied")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
{translate(lang, "access_denied.hint")}
|
||||||
|
</p>
|
||||||
|
{hasDetail && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{serverDetail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
|
|
||||||
export interface ErrorStateProps {
|
export interface ErrorStateProps {
|
||||||
/** Error message to display. If not provided, uses generic i18n message. */
|
/** Error message to display. If not provided, uses generic i18n message. */
|
||||||
@@ -65,7 +66,10 @@ export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
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"
|
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}
|
onClick={() => {
|
||||||
|
triggerHapticLight();
|
||||||
|
onRetry();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t("error.retry")}
|
{t("error.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -4,4 +4,4 @@
|
|||||||
|
|
||||||
export { LoadingState } from "./LoadingState";
|
export { LoadingState } from "./LoadingState";
|
||||||
export { ErrorState } from "./ErrorState";
|
export { ErrorState } from "./ErrorState";
|
||||||
export { AccessDenied } from "./AccessDenied";
|
export { AccessDeniedScreen } from "./AccessDeniedScreen";
|
||||||
|
|||||||
@@ -53,12 +53,15 @@ function SheetContent({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
onCloseAnimationEnd,
|
onCloseAnimationEnd,
|
||||||
onAnimationEnd,
|
onAnimationEnd,
|
||||||
|
overlayClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
showCloseButton?: boolean
|
showCloseButton?: boolean
|
||||||
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
|
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
|
||||||
onCloseAnimationEnd?: () => void
|
onCloseAnimationEnd?: () => void
|
||||||
|
/** Optional class name applied to the overlay (e.g. backdrop-blur-md). */
|
||||||
|
overlayClassName?: string
|
||||||
}) {
|
}) {
|
||||||
const useForceMount = Boolean(onCloseAnimationEnd)
|
const useForceMount = Boolean(onCloseAnimationEnd)
|
||||||
|
|
||||||
@@ -74,13 +77,16 @@ function SheetContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay forceMount={useForceMount ? true : undefined} />
|
<SheetOverlay
|
||||||
|
forceMount={useForceMount ? true : undefined}
|
||||||
|
className={overlayClassName}
|
||||||
|
/>
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
forceMount={useForceMount ? true : undefined}
|
forceMount={useForceMount ? true : undefined}
|
||||||
onAnimationEnd={handleAnimationEnd}
|
onAnimationEnd={handleAnimationEnd}
|
||||||
className={cn(
|
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",
|
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:duration-300",
|
||||||
side === "right" &&
|
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",
|
"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" &&
|
side === "left" &&
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ vi.mock("@telegram-apps/sdk-react", () => ({
|
|||||||
isThemeParamsDark: vi.fn(),
|
isThemeParamsDark: vi.fn(),
|
||||||
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppBackgroundColor: { isAvailable: vi.fn(() => false) },
|
||||||
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
setMiniAppHeaderColor: { isAvailable: vi.fn(() => false) },
|
||||||
|
setMiniAppBottomBarColor: { isAvailable: vi.fn(() => false) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("getFallbackScheme", () => {
|
describe("getFallbackScheme", () => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isThemeParamsDark,
|
isThemeParamsDark,
|
||||||
setMiniAppBackgroundColor,
|
setMiniAppBackgroundColor,
|
||||||
setMiniAppHeaderColor,
|
setMiniAppHeaderColor,
|
||||||
|
setMiniAppBottomBarColor,
|
||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +70,9 @@ export function applyTheme(scheme?: "dark" | "light"): void {
|
|||||||
if (setMiniAppHeaderColor.isAvailable()) {
|
if (setMiniAppHeaderColor.isAvailable()) {
|
||||||
setMiniAppHeaderColor("bg_color");
|
setMiniAppHeaderColor("bg_color");
|
||||||
}
|
}
|
||||||
|
if (setMiniAppBottomBarColor.isAvailable()) {
|
||||||
|
setMiniAppBottomBarColor("bottom_bar_bg_color");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -67,9 +67,12 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"current_duty.shift_tz": "Shift ({tz}):",
|
"current_duty.shift_tz": "Shift ({tz}):",
|
||||||
"current_duty.shift_local": "Shift (your time):",
|
"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.remaining_label": "Remaining",
|
||||||
|
"current_duty.remaining_value": "{hours}h {minutes}min",
|
||||||
"current_duty.ends_at": "Until end of shift at {time}",
|
"current_duty.ends_at": "Until end of shift at {time}",
|
||||||
"current_duty.back": "Back to calendar",
|
"current_duty.back": "Back to calendar",
|
||||||
"current_duty.close": "Close",
|
"current_duty.close": "Close",
|
||||||
|
"current_duty.open_calendar": "Open calendar",
|
||||||
"current_duty.contact_info_not_set": "Contact info not set",
|
"current_duty.contact_info_not_set": "Contact info not set",
|
||||||
"error_boundary.message": "Something went wrong.",
|
"error_boundary.message": "Something went wrong.",
|
||||||
"error_boundary.description": "An unexpected error occurred. Try reloading the app.",
|
"error_boundary.description": "An unexpected error occurred. Try reloading the app.",
|
||||||
@@ -78,6 +81,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.description": "The page you are looking for does not exist.",
|
"not_found.description": "The page you are looking for does not exist.",
|
||||||
"not_found.open_calendar": "Open calendar",
|
"not_found.open_calendar": "Open calendar",
|
||||||
"access_denied.hint": "Open the app again from Telegram.",
|
"access_denied.hint": "Open the app again from Telegram.",
|
||||||
|
"access_denied.reload": "Reload",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -140,9 +144,12 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"current_duty.shift_tz": "Смена ({tz}):",
|
"current_duty.shift_tz": "Смена ({tz}):",
|
||||||
"current_duty.shift_local": "Смена (ваше время):",
|
"current_duty.shift_local": "Смена (ваше время):",
|
||||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||||
|
"current_duty.remaining_label": "Осталось",
|
||||||
|
"current_duty.remaining_value": "{hours}ч {minutes}мин",
|
||||||
"current_duty.ends_at": "До конца смены в {time}",
|
"current_duty.ends_at": "До конца смены в {time}",
|
||||||
"current_duty.back": "Назад к календарю",
|
"current_duty.back": "Назад к календарю",
|
||||||
"current_duty.close": "Закрыть",
|
"current_duty.close": "Закрыть",
|
||||||
|
"current_duty.open_calendar": "Открыть календарь",
|
||||||
"current_duty.contact_info_not_set": "Контактные данные не указаны",
|
"current_duty.contact_info_not_set": "Контактные данные не указаны",
|
||||||
"error_boundary.message": "Что-то пошло не так.",
|
"error_boundary.message": "Что-то пошло не так.",
|
||||||
"error_boundary.description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",
|
"error_boundary.description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",
|
||||||
@@ -151,6 +158,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.description": "Запрашиваемая страница не существует.",
|
"not_found.description": "Запрашиваемая страница не существует.",
|
||||||
"not_found.open_calendar": "Открыть календарь",
|
"not_found.open_calendar": "Открыть календарь",
|
||||||
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||||
|
"access_denied.reload": "Обновить",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Ported from webapp/js/currentDuty.test.js.
|
* Ported from webapp/js/currentDuty.test.js.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { getRemainingTime, findCurrentDuty } from "./current-duty";
|
import { getRemainingTime, findCurrentDuty } from "./current-duty";
|
||||||
import type { DutyWithUser } from "@/types";
|
import type { DutyWithUser } from "@/types";
|
||||||
|
|
||||||
|
|||||||
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
26
webapp-next/src/lib/telegram-android-perf.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Detects Android Telegram Mini App performance class from User-Agent and sets
|
||||||
|
* data-perf on document.documentElement so CSS can reduce animations on low-end devices.
|
||||||
|
* @see https://core.telegram.org/bots/webapps#additional-data-in-user-agent
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { retrieveAndroidDeviceData } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
const DATA_ATTR = "data-perf";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs once: if running in Telegram on Android with LOW performance class,
|
||||||
|
* sets data-perf="low" on the document root for CSS to minimize animations.
|
||||||
|
*/
|
||||||
|
export function applyAndroidPerformanceClass(): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
try {
|
||||||
|
const data = retrieveAndroidDeviceData();
|
||||||
|
const perf = data?.performanceClass;
|
||||||
|
if (perf === "LOW") {
|
||||||
|
document.documentElement.setAttribute(DATA_ATTR, "low");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not in Telegram or not Android; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
19
webapp-next/src/lib/telegram-haptic.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Triggers Telegram Mini App haptic feedback when available.
|
||||||
|
* Use on primary actions and key interactions to mimic native Telegram behavior.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hapticFeedbackImpactOccurred } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Triggers light impact haptic feedback. No-op when not in Telegram or unsupported.
|
||||||
|
*/
|
||||||
|
export function triggerHapticLight(): void {
|
||||||
|
try {
|
||||||
|
if (hapticFeedbackImpactOccurred.isAvailable()) {
|
||||||
|
hapticFeedbackImpactOccurred("light");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK not available; ignore.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Single-call wrapper for Telegram Mini App ready().
|
* Single-call wrapper for Telegram Mini App ready() and expand().
|
||||||
* Called once when the first visible screen has finished loading so Telegram
|
* Called once when the first visible screen has finished loading so Telegram
|
||||||
* hides its native loading animation only after our content is ready.
|
* hides its native loading animation only after our content is ready.
|
||||||
|
* Also expands the Mini App to full height when supported.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { miniAppReady } from "@telegram-apps/sdk-react";
|
import { miniAppReady, expandViewport } from "@telegram-apps/sdk-react";
|
||||||
|
|
||||||
let readyCalled = false;
|
let readyCalled = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calls Telegram miniAppReady() at most once per session.
|
* Calls Telegram miniAppReady() at most once per session, then expandViewport()
|
||||||
|
* when available so the app opens to full height.
|
||||||
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
||||||
*/
|
*/
|
||||||
export function callMiniAppReadyOnce(): void {
|
export function callMiniAppReadyOnce(): void {
|
||||||
@@ -19,6 +21,9 @@ export function callMiniAppReadyOnce(): void {
|
|||||||
miniAppReady();
|
miniAppReady();
|
||||||
readyCalled = true;
|
readyCalled = true;
|
||||||
}
|
}
|
||||||
|
if (expandViewport.isAvailable()) {
|
||||||
|
expandViewport();
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// SDK not available or not in Mini App context; no-op.
|
// SDK not available or not in Mini App context; no-op.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface AppState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
accessDenied: boolean;
|
accessDenied: boolean;
|
||||||
/** Server detail from API 403 response; shown in AccessDenied component. */
|
/** Server detail from API 403 response; shown in AccessDeniedScreen. */
|
||||||
accessDeniedDetail: string | null;
|
accessDeniedDetail: string | null;
|
||||||
currentView: CurrentView;
|
currentView: CurrentView;
|
||||||
selectedDay: string | null;
|
selectedDay: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user