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.
This commit is contained in:
2026-03-04 11:22:45 +03:00
parent 6adec62b5f
commit d99912b080
3 changed files with 81 additions and 23 deletions

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

@@ -166,15 +166,14 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
aria-label={t("loading")}
>
<Card className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty">
<CardHeader>
<CardContent className="flex flex-col gap-4 pt-6">
<div className="flex items-center gap-2">
<Skeleton className="size-2.5 shrink-0 rounded-full" />
<Skeleton className="h-5 w-32" />
<Skeleton className="size-2 shrink-0 rounded-full" />
<Skeleton className="h-5 w-24 rounded-full" />
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4 pt-1">
<div className="flex flex-col gap-2">
<Skeleton className="h-6 w-48" />
<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" />
@@ -246,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>
@@ -287,41 +293,45 @@ 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-4 pt-1">
<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-lg font-semibold text-foreground leading-tight"
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} {shiftStr}
{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.5 flex flex-col gap-0.5"
className="rounded-lg bg-duty/10 px-3 py-2.5 flex flex-col gap-1"
aria-live="polite"
aria-atomic="true"
>
<span className="text-xs text-muted-foreground">
{t("current_duty.remaining_label")}
</span>
<span className="text-base font-semibold text-foreground tabular-nums">
<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>
<section className="flex flex-col gap-2 border-t border-border/50 pt-4" aria-label={t("contact.label")}>
{hasContacts ? (

View File

@@ -72,6 +72,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"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.",
@@ -147,6 +148,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"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": "Произошла непредвиденная ошибка. Попробуйте перезагрузить приложение.",