6 Commits

Author SHA1 Message Date
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
9 changed files with 260 additions and 37 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,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [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
### Added
@@ -44,5 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Input validation and initData hash verification for Miniapp access.
- Optional CORS and init_data_max_age; use env for secrets.
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.0.3...HEAD
[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 -->
[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

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "duty-teller"
version = "0.1.0"
version = "2.0.3"
description = "Telegram bot for team duty shift calendar and group reminder"
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -49,13 +49,18 @@ export function CalendarHeader({
>
<ChevronLeftIcon className="size-5" aria-hidden />
</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
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-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>
</div>
<Button

View File

@@ -57,6 +57,18 @@ describe("CurrentDutyView", () => {
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 () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
@@ -81,4 +93,38 @@ describe("CurrentDutyView", () => {
).toBeInTheDocument();
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([]);
});
});

View File

@@ -29,6 +29,7 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { DutyWithUser } from "@/types";
export interface CurrentDutyViewProps {
@@ -164,14 +165,32 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
aria-live="polite"
aria-label={t("loading")}
>
<span
className="block size-8 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
<p className="text-muted-foreground m-0">{t("loading")}</p>
<Button variant="outline" onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
<div className="flex flex-col gap-2">
<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>
);
}
@@ -226,10 +245,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
{t("current_duty.no_duty")}
</p>
</CardContent>
<CardFooter>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
<Button
variant="outline"
onClick={onBack}
aria-label={t("current_duty.open_calendar")}
>
{t("current_duty.open_calendar")}
</Button>
</CardFooter>
</Card>
</div>
@@ -244,11 +270,10 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
const endTime = formatHHMM(duty.end_at);
const shiftStr = `${startDDMM} ${startTime}${endDDMM} ${endTime}`;
const rem = remaining ?? getRemainingTime(duty.end_at);
const remainingStr = t("current_duty.remaining", {
const remainingValueStr = t("current_duty.remaining_value", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
const endsAtStr = t("current_duty.ends_at", { time: endTime });
const displayTz =
typeof window !== "undefined" &&
@@ -268,33 +293,47 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
role="article"
aria-labelledby="current-duty-title"
>
<CardHeader>
<CardTitle
id="current-duty-title"
className="flex items-center gap-2"
>
<span
className="inline-block size-2.5 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{t("current_duty.title")}
</CardTitle>
<CardHeader className="sr-only">
<CardTitle id="current-duty-title">{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="font-medium text-foreground" id="current-duty-name">
{duty.full_name}
</p>
<p className="text-sm text-muted-foreground">
{shiftLabel} {shiftStr}
</p>
<CardContent className="flex flex-col gap-4 pt-6">
<span
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"
aria-hidden
>
<span className="size-2 shrink-0 rounded-full bg-duty animate-pulse motion-reduce:animate-none" />
{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
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-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>
<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 ? (
<ContactLinks
phone={duty.phone}
@@ -308,6 +347,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
{t("current_duty.contact_info_not_set")}
</p>
)}
</section>
</CardContent>
<CardFooter>
<Button

View File

@@ -67,9 +67,12 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"current_duty.shift_tz": "Shift ({tz}):",
"current_duty.shift_local": "Shift (your time):",
"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.back": "Back to calendar",
"current_duty.close": "Close",
"current_duty.open_calendar": "Open calendar",
"current_duty.contact_info_not_set": "Contact info not set",
"error_boundary.message": "Something went wrong.",
"error_boundary.description": "An unexpected error occurred. Try reloading the app.",
@@ -140,9 +143,12 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"current_duty.shift_tz": "Смена ({tz}):",
"current_duty.shift_local": "Смена (ваше время):",
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
"current_duty.remaining_label": "Осталось",
"current_duty.remaining_value": "{hours}ч {minutes}мин",
"current_duty.ends_at": "До конца смены в {time}",
"current_duty.back": "Назад к календарю",
"current_duty.close": "Закрыть",
"current_duty.open_calendar": "Открыть календарь",
"current_duty.contact_info_not_set": "Контактные данные не указаны",
"error_boundary.message": "Что-то пошло не так.",
"error_boundary.description": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",

View File

@@ -3,7 +3,7 @@
* 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 type { DutyWithUser } from "@/types";