Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3244fbe505 | |||
| d99912b080 | |||
| 6adec62b5f | |||
| a8d4afb101 |
@@ -7,6 +7,10 @@ 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.)
|
||||
@@ -48,7 +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.2...HEAD
|
||||
[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 -->
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "duty-teller"
|
||||
version = "2.0.2"
|
||||
version = "2.0.3"
|
||||
description = "Telegram bot for team duty shift calendar and group reminder"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user