6 Commits
v2.1.1 ... main

Author SHA1 Message Date
0ee77ee5c1 chore(release): v2.1.3
All checks were successful
CI / lint-and-test (push) Successful in 1m7s
Docker Build and Release / build-and-push (push) Successful in 1m30s
Docker Build and Release / release (push) Successful in 9s
Made-with: Cursor
2026-03-07 00:40:47 +03:00
3f34c7951f feat: enhance CalendarDay component with holiday outline styling
All checks were successful
CI / lint-and-test (push) Successful in 1m4s
- Added a new CSS variable for today's holiday outline to improve visual distinction on the calendar.
- Updated the CalendarDay component to apply the holiday outline styling when the day is marked as today and has associated event summaries.
- Enhanced unit tests to verify the correct application of styles for today and holiday events, ensuring proper rendering and user experience.
2026-03-07 00:35:26 +03:00
dc87b3ad97 feat: update copy functionality in ContactLinks component
- Modified the copy button behavior to display "Copied" via button aria-label instead of a tooltip after a successful copy action.
- Enhanced the component to revert the copy button icon when switching between phone number and Telegram username copy actions.
- Updated tests to reflect changes in copy button functionality and ensure proper rendering based on user interactions.
2026-03-07 00:11:36 +03:00
7cd00893ad chore(release): v2.1.2
All checks were successful
CI / lint-and-test (push) Successful in 1m5s
Docker Build and Release / build-and-push (push) Successful in 50s
Docker Build and Release / release (push) Successful in 9s
2026-03-06 20:12:55 +03:00
95d3af4930 fix: improve styling in ContactLinks component
All checks were successful
CI / lint-and-test (push) Successful in 1m4s
- Updated the row class in the ContactLinks component to include a shadow effect, enhancing visual depth and user experience.
- Ensured consistent styling across light and dark themes by adjusting background properties.
2026-03-06 19:11:52 +03:00
24d6ecbedb feat: add copy functionality for phone and Telegram username in ContactLinks component
- Implemented copy buttons for phone number and Telegram username in the ContactLinks component, enhancing user interaction.
- Integrated tooltip feedback to indicate successful copy actions.
- Updated tests to cover new copy functionality and ensure proper rendering of copy buttons based on props.
- Added localization support for new copy-related strings in the i18n messages.
2026-03-06 18:55:56 +03:00
12 changed files with 433 additions and 26 deletions

View File

@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [2.1.3] - 2025-03-07
(No changes documented; release for version sync.)
## [2.1.2] - 2025-03-06
(No changes documented; release for version sync.)
## [2.1.1] - 2025-03-06
(No changes documented; release for version sync.)
@@ -64,7 +72,9 @@ 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.1.1...HEAD
[Unreleased]: https://github.com/your-org/duty-teller/compare/v2.1.3...HEAD
[2.1.3]: https://github.com/your-org/duty-teller/releases/tag/v2.1.3
[2.1.2]: https://github.com/your-org/duty-teller/releases/tag/v2.1.2
[2.1.1]: https://github.com/your-org/duty-teller/releases/tag/v2.1.1 <!-- placeholder: set to your repo URL when publishing -->
[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 -->

View File

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

View File

@@ -76,6 +76,7 @@
--today-hover: color-mix(in srgb, var(--bg) 15%, var(--today));
--today-border: color-mix(in srgb, var(--today) 35%, transparent);
--today-border-selected: color-mix(in srgb, var(--bg) 50%, transparent);
--today-holiday-outline: color-mix(in srgb, var(--bg) 28%, var(--today));
--today-gradient-end: color-mix(in srgb, var(--today) 15%, transparent);
--muted-fade: color-mix(in srgb, var(--muted) 40%, transparent);
--handle-bg: color-mix(in srgb, var(--muted) 80%, var(--text));

View File

@@ -76,4 +76,20 @@ describe("CalendarDay", () => {
const button = screen.getByRole("button", { name: /15/ });
expect(button.getAttribute("aria-disabled")).not.toBe("true");
});
it("applies today base and holiday outline when isToday and eventSummaries are set", () => {
render(
<CalendarDay
{...defaultProps}
isOtherMonth={false}
isToday={true}
eventSummaries={["Holiday"]}
onDayClick={() => {}}
/>
);
const button = screen.getByRole("button", { name: /15/ });
expect(button.className).toMatch(/bg-today|today/);
expect(button.className).toMatch(/ring-2|today-holiday-outline/);
expect(button.getAttribute("aria-disabled")).not.toBe("true");
});
});

View File

@@ -45,6 +45,7 @@ function CalendarDayInner({
[duties]
);
const hasEvent = eventSummaries.length > 0;
const isTodayHoliday = isToday && hasEvent;
const showIndicator = !isOtherMonth;
const hasAny = duties.length > 0 || hasEvent;
@@ -82,10 +83,9 @@ function CalendarDayInner({
showIndicator && hasAny && "font-bold",
showIndicator &&
hasEvent &&
!isToday &&
"bg-[linear-gradient(135deg,var(--surface)_0%,var(--today-gradient-end)_100%)] border border-[var(--today-border)]",
isToday &&
hasEvent &&
"bg-today text-[var(--bg)] border border-[var(--today-border-selected)]"
isTodayHoliday && "ring-1 ring-inset ring-[var(--today-holiday-outline)]"
)}
onClick={(e) => {
if (isOtherMonth) return;

View File

@@ -4,13 +4,19 @@
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, act } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { TooltipProvider } from "@/components/ui/tooltip";
import { resetAppStore } from "@/test/test-utils";
function renderWithTooltip(ui: React.ReactElement) {
return render(<TooltipProvider>{ui}</TooltipProvider>);
}
const openPhoneLinkMock = vi.fn();
const openTelegramProfileMock = vi.fn();
const triggerHapticLightMock = vi.fn();
const copyToClipboardMock = vi.fn();
vi.mock("@/lib/open-phone-link", () => ({
openPhoneLink: (...args: unknown[]) => openPhoneLinkMock(...args),
@@ -21,6 +27,9 @@ vi.mock("@/lib/telegram-link", () => ({
vi.mock("@/lib/telegram-haptic", () => ({
triggerHapticLight: () => triggerHapticLightMock(),
}));
vi.mock("@/lib/copy-to-clipboard", () => ({
copyToClipboard: (...args: unknown[]) => copyToClipboardMock(...args),
}));
describe("ContactLinks", () => {
beforeEach(() => {
@@ -28,6 +37,7 @@ describe("ContactLinks", () => {
openPhoneLinkMock.mockClear();
openTelegramProfileMock.mockClear();
triggerHapticLightMock.mockClear();
copyToClipboardMock.mockClear();
});
it("returns null when phone and username are missing", () => {
@@ -100,4 +110,152 @@ describe("ContactLinks", () => {
expect(openTelegramProfileMock).toHaveBeenCalledWith("alice_dev");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
describe("showCopyButtons with layout block", () => {
it("renders copy phone button with aria-label when phone is present", () => {
renderWithTooltip(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
});
it("renders copy Telegram button with aria-label when username is present", () => {
renderWithTooltip(
<ContactLinks
phone={null}
username="alice_dev"
layout="block"
showCopyButtons
/>
);
expect(
screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
})
).toBeInTheDocument();
});
it("calls copyToClipboard with raw phone and triggerHapticLight when copy phone is clicked", async () => {
copyToClipboardMock.mockResolvedValue(true);
renderWithTooltip(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(copyToClipboardMock).toHaveBeenCalledWith("+79991234567");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
it("calls copyToClipboard with @username when copy Telegram is clicked", async () => {
copyToClipboardMock.mockResolvedValue(true);
renderWithTooltip(
<ContactLinks
phone={null}
username="alice_dev"
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(copyToClipboardMock).toHaveBeenCalledWith("@alice_dev");
expect(triggerHapticLightMock).toHaveBeenCalled();
});
it("shows Copied via button aria-label and no tooltip after successful copy", async () => {
copyToClipboardMock.mockResolvedValue(true);
render(
<ContactLinks
phone="+79991234567"
username={null}
layout="block"
showCopyButtons
/>
);
const copyBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
await act(async () => {
fireEvent.click(copyBtn);
});
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
});
it("reverts first copy button to Copy icon when copying the other field", async () => {
copyToClipboardMock.mockResolvedValue(true);
render(
<ContactLinks
phone="+79991234567"
username="alice_dev"
layout="block"
showCopyButtons
/>
);
const copyPhoneBtn = screen.getByRole("button", {
name: /Copy phone number|Скопировать номер/i,
});
const copyTelegramBtn = screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
});
await act(async () => {
fireEvent.click(copyPhoneBtn);
});
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
await act(async () => {
fireEvent.click(copyTelegramBtn);
});
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Copied|Скопировано/i })
).toBeInTheDocument();
});
it("does not show copy buttons when showCopyButtons is false", () => {
render(
<ContactLinks
phone="+79991234567"
username="bob"
layout="block"
showCopyButtons={false}
/>
);
expect(
screen.queryByRole("button", { name: /Copy phone number|Скопировать номер/i })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Copy Telegram username|Скопировать логин Telegram/i })
).not.toBeInTheDocument();
});
});
});

View File

@@ -5,14 +5,17 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { openPhoneLink } from "@/lib/open-phone-link";
import { openTelegramProfile } from "@/lib/telegram-link";
import { triggerHapticLight } from "@/lib/telegram-haptic";
import { Button } from "@/components/ui/button";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
import { Phone as PhoneIcon, Send as TelegramIcon, Copy, Check } from "lucide-react";
const COPIED_RESET_MS = 1800;
export interface ContactLinksProps {
phone?: string | null;
@@ -21,6 +24,8 @@ export interface ContactLinksProps {
showLabels?: boolean;
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
contextLabel?: string;
/** When true and layout is "block", show copy buttons for phone and Telegram username. */
showCopyButtons?: boolean;
className?: string;
}
@@ -36,14 +41,38 @@ export function ContactLinks({
layout = "inline",
showLabels = true,
contextLabel,
showCopyButtons = false,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const [copiedKind, setCopiedKind] = useState<"phone" | "telegram" | null>(null);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
const showCopy = layout === "block" && showCopyButtons;
useEffect(() => () => clearCopiedTimeout(), []);
const clearCopiedTimeout = () => {
if (copiedTimeoutRef.current) {
clearTimeout(copiedTimeoutRef.current);
copiedTimeoutRef.current = null;
}
};
const showCopiedFeedback = (kind: "phone" | "telegram") => {
clearCopiedTimeout();
setCopiedKind(kind);
copiedTimeoutRef.current = setTimeout(() => {
setCopiedKind(null);
copiedTimeoutRef.current = null;
}, COPIED_RESET_MS);
};
if (!hasPhone && !hasUsername) return null;
const handlePhoneClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
@@ -52,6 +81,22 @@ export function ContactLinks({
triggerHapticLight();
};
const handleCopyPhone = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
triggerHapticLight();
const rawPhone = String(phone).trim();
const ok = await copyToClipboard(rawPhone);
if (ok) showCopiedFeedback("phone");
};
const handleCopyTelegram = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
triggerHapticLight();
const text = `@${cleanUsername}`;
const ok = await copyToClipboard(text);
if (ok) showCopiedFeedback("telegram");
};
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
@@ -60,32 +105,44 @@ export function ContactLinks({
: t("contact.telegram");
if (layout === "block") {
const rowClass =
"flex h-12 items-center gap-0 rounded-md border border-input bg-background shadow-xs text-accent hover:bg-accent/10 hover:text-accent dark:bg-input/30 dark:hover:bg-input/50";
return (
<div className={cn("flex flex-col gap-2", className)}>
{hasPhone && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<div className={rowClass}>
<a
href={`tel:${String(phone).trim()}`}
aria-label={ariaCall}
onClick={handlePhoneClick}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
<PhoneIcon className="size-5 shrink-0" aria-hidden />
<span className="truncate">{formatPhoneDisplay(phone!)}</span>
</a>
</Button>
{showCopy && (
<button
type="button"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
aria-label={copiedKind === "phone" ? t("contact.copied") : t("contact.copy_phone")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleCopyPhone(e);
}}
>
{copiedKind === "phone" ? (
<Check className="size-5" aria-hidden />
) : (
<Copy className="size-5" aria-hidden />
)}
</button>
)}
</div>
)}
{hasUsername && (
<Button
variant="outline"
size="sm"
className="h-12 justify-start gap-3 px-4 text-accent hover:bg-accent/10 hover:text-accent"
asChild
>
<div className={rowClass}>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
@@ -96,11 +153,30 @@ export function ContactLinks({
openTelegramProfile(cleanUsername);
triggerHapticLight();
}}
className="flex min-w-0 flex-1 items-center gap-3 px-4 py-2"
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
<TelegramIcon className="size-5 shrink-0" aria-hidden />
<span className="truncate">@{cleanUsername}</span>
</a>
</Button>
{showCopy && (
<button
type="button"
className="flex h-12 w-12 shrink-0 items-center justify-center rounded-r-[calc(theme(borderRadius.md)-1px)] text-accent hover:bg-accent/15 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-[-2px]"
aria-label={copiedKind === "telegram" ? t("contact.copied") : t("contact.copy_telegram")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
void handleCopyTelegram(e);
}}
>
{copiedKind === "telegram" ? (
<Check className="size-5" aria-hidden />
) : (
<Copy className="size-5" aria-hidden />
)}
</button>
)}
</div>
)}
</div>
);

View File

@@ -6,6 +6,7 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CurrentDutyView } from "./CurrentDutyView";
import { TooltipProvider } from "@/components/ui/tooltip";
import { resetAppStore } from "@/test/test-utils";
vi.mock("@/hooks/use-telegram-auth", () => ({
@@ -148,4 +149,37 @@ describe("CurrentDutyView", () => {
expect(onBack).toHaveBeenCalled();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
it("shows copy phone and copy Telegram buttons when duty has contacts", 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() + 60 * 60 * 1000);
const dutyWithContacts = {
id: 1,
user_id: 1,
start_at: start.toISOString(),
end_at: end.toISOString(),
event_type: "duty" as const,
full_name: "Test User",
phone: "+79991234567",
username: "testuser",
};
vi.mocked(fetchDuties).mockResolvedValue([dutyWithContacts]);
render(
<TooltipProvider>
<CurrentDutyView onBack={vi.fn()} />
</TooltipProvider>
);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByRole("button", { name: /Copy phone number|Скопировать номер/i })
).toBeInTheDocument();
expect(
screen.getByRole("button", {
name: /Copy Telegram username|Скопировать логин Telegram/i,
})
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
});

View File

@@ -334,6 +334,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
showCopyButtons={true}
/>
) : (
<p className="text-sm text-muted-foreground">

View File

@@ -62,6 +62,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"contact.telegram": "Telegram",
"contact.aria_call": "Call {name}",
"contact.aria_telegram": "Message {name} on Telegram",
"contact.copy_phone": "Copy phone number",
"contact.copy_telegram": "Copy Telegram username",
"contact.copied": "Copied",
"current_duty.title": "Current Duty",
"current_duty.no_duty": "No one is on duty right now",
"current_duty.shift": "Shift",
@@ -161,6 +164,9 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
"contact.telegram": "Telegram",
"contact.aria_call": "Позвонить {name}",
"contact.aria_telegram": "Написать {name} в Telegram",
"contact.copy_phone": "Скопировать номер",
"contact.copy_telegram": "Скопировать логин Telegram",
"contact.copied": "Скопировано",
"current_duty.title": "Сейчас дежурит",
"current_duty.no_duty": "Сейчас никто не дежурит",
"current_duty.shift": "Смена",

View File

@@ -0,0 +1,64 @@
/**
* Unit tests for copyToClipboard: Clipboard API and execCommand fallback.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
describe("copyToClipboard", () => {
const writeTextMock = vi.fn();
beforeEach(async () => {
vi.resetModules();
writeTextMock.mockReset();
Object.defineProperty(navigator, "clipboard", {
value: { writeText: writeTextMock },
configurable: true,
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns true when navigator.clipboard.writeText succeeds", async () => {
writeTextMock.mockResolvedValue(undefined);
const { copyToClipboard } = await import("./copy-to-clipboard");
const result = await copyToClipboard("+79991234567");
expect(result).toBe(true);
expect(writeTextMock).toHaveBeenCalledWith("+79991234567");
});
it("returns false when text is empty", async () => {
const { copyToClipboard } = await import("./copy-to-clipboard");
expect(await copyToClipboard("")).toBe(false);
expect(await copyToClipboard(" ")).toBe(false);
expect(writeTextMock).not.toHaveBeenCalled();
});
it("returns false when clipboard.writeText rejects", async () => {
writeTextMock.mockRejectedValue(new Error("Permission denied"));
const { copyToClipboard } = await import("./copy-to-clipboard");
const result = await copyToClipboard("hello");
expect(result).toBe(false);
});
it("uses execCommand fallback when clipboard is missing", async () => {
Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
const execCommandMock = vi.fn().mockReturnValue(true);
(document as unknown as { execCommand?: ReturnType<typeof vi.fn> }).execCommand =
execCommandMock;
vi.resetModules();
const { copyToClipboard } = await import("./copy-to-clipboard");
const result = await copyToClipboard("fallback text");
expect(result).toBe(true);
expect(execCommandMock).toHaveBeenCalledWith("copy");
});
});

View File

@@ -0,0 +1,41 @@
/**
* Copy text to the clipboard. Uses Clipboard API with execCommand fallback.
* Used for copying phone/Telegram on the current duty view.
*/
/**
* Copies the given text to the clipboard.
*
* @param text - Raw text to copy (e.g. phone number or @username).
* @returns Promise resolving to true on success, false on failure. Errors are logged, not thrown.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof text !== "string" || text.trim() === "") {
return false;
}
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.warn("clipboard.writeText failed", err);
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch (err) {
console.warn("execCommand copy fallback failed", err);
return false;
}
}