16 Commits

Author SHA1 Message Date
68b1884b73 chore(release): v2.0.6
All checks were successful
CI / lint-and-test (push) Successful in 1m2s
Docker Build and Release / build-and-push (push) Successful in 1m1s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 22:12:22 +03:00
fb786c4c3a refactor: remove haptic feedback triggers from calendar and duty components
- Eliminated triggerHapticLight calls from CalendarPage, CalendarDay, DayDetail, and DutyTimelineCard components to streamline user interaction.
- This change focuses on improving performance and reducing unnecessary feedback in the user interface.
2026-03-04 22:11:07 +03:00
07e22079ee feat: enhance CSS and components for Telegram Mini App performance
- Updated CSS to utilize viewport variables for safe area insets and stable height, improving layout consistency across devices.
- Introduced haptic feedback triggers in various components to enhance user interaction, mimicking native Telegram behavior.
- Added functionality to detect Android performance class, minimizing animations on low-performance devices for better user experience.
- Refactored components to incorporate new CSS classes for content safety and improved responsiveness.
2026-03-04 19:19:14 +03:00
13aba85e28 chore(release): v2.0.4
All checks were successful
CI / lint-and-test (push) Successful in 1m2s
Docker Build and Release / build-and-push (push) Successful in 49s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 18:39:37 +03:00
8ad8dffd0a feat: implement post-init function for application startup tasks
- Added _post_init function to run startup tasks, including restoring group pin jobs and resolving bot username.
- Updated main function to utilize the new _post_init for improved application initialization process.
2026-03-04 18:38:20 +03:00
6d087d1b26 feat: prevent default focus behavior on DayDetail component
- Added onOpenAutoFocus handler to DayDetail component to prevent default focus behavior when the overlay opens.
- This enhancement improves user experience by maintaining focus control during component interactions.
2026-03-04 18:18:15 +03:00
94545dc8c3 feat: add overlay class support to SheetContent and DayDetail components
- Introduced overlayClassName prop to SheetContent for customizable overlay styling.
- Updated DayDetail component to utilize the new overlayClassName for enhanced visual effects.
- Improved user experience by allowing dynamic styling of the overlay during component rendering.
2026-03-04 18:03:35 +03:00
33359f589a feat: implement AccessDeniedScreen and enhance error handling
- Introduced AccessDeniedScreen component for improved user experience when access is denied, replacing the previous AccessDenied component.
- Updated CurrentDutyView and CalendarPage to handle access denied scenarios, displaying the new screen appropriately.
- Enhanced tests for CurrentDutyView and AccessDeniedScreen to ensure correct rendering and functionality under access denied conditions.
- Refactored localization messages to include new labels for access denied scenarios in both English and Russian.
2026-03-04 17:51:30 +03:00
3244fbe505 chore(release): v2.0.3
All checks were successful
CI / lint-and-test (push) Successful in 1m3s
Docker Build and Release / build-and-push (push) Successful in 51s
Docker Build and Release / release (push) Successful in 15s
Made-with: Cursor
2026-03-04 11:24:42 +03:00
d99912b080 feat: enhance CurrentDutyView with new functionality and improved tests
- Added a button to open the calendar in the no-duty view, triggering the onBack function.
- Implemented tests for the new button functionality and error handling in the CurrentDutyView component.
- Updated localization messages to include the new "Open calendar" label in both English and Russian.
- Refactored layout and styling for better user experience and accessibility.
2026-03-04 11:22:45 +03:00
6adec62b5f feat: enhance CurrentDutyView with loading skeletons and improved localization
- Added Skeleton components to CurrentDutyView for better loading state representation.
- Updated localization messages to include new labels for remaining time display.
- Refactored remaining time display logic for clarity and improved user experience.
2026-03-04 11:16:07 +03:00
a8d4afb101 refactor: improve layout and styling in CalendarHeader component
- Adjusted the layout of the CalendarHeader to enhance visual hierarchy by separating the year and month display.
- Updated CSS classes for better alignment and spacing, ensuring a more polished user interface.
- Enhanced accessibility by maintaining aria attributes for live updates.
2026-03-04 11:01:17 +03:00
106e42a81d chore(release): v2.0.2
All checks were successful
CI / lint-and-test (push) Successful in 1m6s
Docker Build and Release / build-and-push (push) Successful in 50s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-04 10:22:10 +03:00
3c4c28a1ac feat: add project release and run tests skills documentation
- Introduced SKILL.md files for project release and running tests in the duty-teller project.
- Documented the project release workflow, including steps for updating CHANGELOG, committing changes, and tagging releases.
- Provided instructions for running backend (pytest) and frontend (Vitest) tests, including prerequisites and failure handling.
- Enhanced user guidance for verifying changes and ensuring test coverage.
2026-03-04 10:18:43 +03:00
119661628e refactor: enhance styling and structure in DayDetailContent component
All checks were successful
CI / lint-and-test (push) Successful in 1m3s
Docker Build and Release / build-and-push (push) Successful in 55s
Docker Build and Release / release (push) Successful in 14s
- Updated list styling in DayDetailContent to use a cleaner format without bullet points, improving visual consistency.
- Added decorative bullet points for duty, unavailable, vacation, and event summaries to enhance readability.
- Adjusted spacing and layout for better alignment of list items, ensuring a more polished user interface.
2026-03-04 10:06:22 +03:00
336e6d48c5 fix: adjust animation duration for SheetContent component
- Updated the animation duration for the open state of the SheetContent component from 500ms to 300ms, improving the responsiveness of the UI during state transitions.
2026-03-03 19:47:49 +03:00
30 changed files with 706 additions and 179 deletions

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

View 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 Vitests 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]"`).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
}); });

View File

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

View File

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

View File

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

View File

@@ -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}</>;

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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";

View File

@@ -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" &&

View File

@@ -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", () => {

View File

@@ -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");
}
} }
/** /**

View File

@@ -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": "Обновить",
}, },
}; };

View File

@@ -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";

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

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

View File

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

View File

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