feat: migrate to Next.js for Mini App and enhance project structure

- Replaced the previous webapp with a new Mini App built using Next.js, improving performance and maintainability.
- Updated the `.gitignore` to exclude Next.js build artifacts and node modules.
- Revised documentation in `AGENTS.md`, `README.md`, and `architecture.md` to reflect the new Mini App structure and technology stack.
- Enhanced Dockerfile to support the new build process for the Next.js application.
- Updated CI workflow to build and test the Next.js application.
- Added new configuration options for the Mini App, including `MINI_APP_SHORT_NAME` for improved deep linking.
- Refactored frontend testing setup to accommodate the new structure and testing framework.
- Removed legacy webapp files and dependencies to streamline the project.
This commit is contained in:
2026-03-03 16:04:08 +03:00
parent 2de5c1cb81
commit 16bf1a1043
148 changed files with 20240 additions and 7270 deletions

View File

@@ -0,0 +1,76 @@
/**
* Error boundary that catches render errors in the app tree and shows a fallback
* with a reload option. Uses pure i18n (getLang/translate) so it does not depend
* on React context that might be broken.
*/
"use client";
import React from "react";
import { getLang } from "@/i18n/messages";
import { translate } from "@/i18n/messages";
import { Button } from "@/components/ui/button";
interface AppErrorBoundaryProps {
children: React.ReactNode;
}
interface AppErrorBoundaryState {
hasError: boolean;
}
/**
* Catches JavaScript errors in the child tree and renders a fallback UI
* instead of crashing. Provides a Reload button to recover.
*/
export class AppErrorBoundary extends React.Component<
AppErrorBoundaryProps,
AppErrorBoundaryState
> {
constructor(props: AppErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): AppErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
if (typeof console !== "undefined" && console.error) {
console.error("AppErrorBoundary caught an error:", error, errorInfo);
}
}
handleReload = (): void => {
if (typeof window !== "undefined") {
window.location.reload();
}
};
render(): React.ReactNode {
if (this.state.hasError) {
const lang = getLang();
const message = translate(lang, "error_boundary.message");
const reloadLabel = translate(lang, "error_boundary.reload");
return (
<div
className="flex min-h-[200px] flex-col items-center justify-center gap-4 rounded-xl bg-surface py-8 px-4 text-center"
role="alert"
>
<p className="m-0 text-sm font-medium text-foreground">{message}</p>
<Button
type="button"
variant="default"
size="sm"
onClick={this.handleReload}
className="bg-primary text-primary-foreground hover:opacity-90"
>
{reloadLabel}
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,195 @@
/**
* Calendar view layout: header, grid, duty list, day detail.
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
*/
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe";
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList, DutyListSkeleton } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { LoadingState } from "@/components/states/LoadingState";
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). */
const STICKY_HEIGHT_FALLBACK_PX = 268;
export interface CalendarPageProps {
/** Whether the user is allowed (for data loading). */
isAllowed: boolean;
/** Raw initData string for API auth. */
initDataRaw: string | undefined;
}
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const dayDetailRef = useRef<DayDetailHandle>(null);
const calendarStickyRef = useRef<HTMLDivElement>(null);
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
useEffect(() => {
const el = calendarStickyRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setStickyBlockHeight(entry.contentRect.height);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const {
currentMonth,
loading,
error,
accessDenied,
accessDeniedDetail,
duties,
calendarEvents,
selectedDay,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
accessDeniedDetail: s.accessDeniedDetail,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
}))
);
const { retry } = useMonthData({
initDataRaw,
enabled: isAllowed,
});
const now = new Date();
const isCurrentMonth =
currentMonth.getFullYear() === now.getFullYear() &&
currentMonth.getMonth() === now.getMonth();
useAutoRefresh(retry, isCurrentMonth);
const navDisabled = loading || accessDenied || selectedDay !== null;
const handlePrevMonth = useCallback(() => {
if (navDisabled) return;
prevMonth();
}, [navDisabled, prevMonth]);
const handleNextMonth = useCallback(() => {
if (navDisabled) return;
nextMonth();
}, [navDisabled, nextMonth]);
useSwipe(
calendarStickyRef,
handleNextMonth,
handlePrevMonth,
{ threshold: 50, disabled: navDisabled }
);
useStickyScroll(calendarStickyRef);
const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => {
const [y, m] = dateKey.split("-").map(Number);
if (
y !== currentMonth.getFullYear() ||
m !== currentMonth.getMonth() + 1
) {
return;
}
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
},
[currentMonth]
);
const handleCloseDayDetail = useCallback(() => {
setSelectedDay(null);
}, [setSelectedDay]);
const handleGoToToday = useCallback(() => {
setCurrentMonth(new Date());
retry();
}, [setCurrentMonth, retry]);
const isInitialLoad =
loading && duties.length === 0 && calendarEvents.length === 0;
// Signal Telegram to hide loading when calendar first load finishes.
useEffect(() => {
if (!isInitialLoad) {
callMiniAppReadyOnce();
}
}, [isInitialLoad]);
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
ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
>
<CalendarHeader
month={currentMonth}
isLoading={loading}
disabled={navDisabled}
onGoToToday={handleGoToToday}
onRefresh={retry}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
{isInitialLoad ? (
<LoadingState
asPlaceholder
className="min-h-[var(--calendar-block-min-height)]"
/>
) : (
<CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
)}
</div>
{accessDenied && (
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
)}
{!accessDenied && error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!accessDenied && !error && loading && !isInitialLoad ? (
<DutyListSkeleton className="mt-2" />
) : !accessDenied && !error && !isInitialLoad ? (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
) : null}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Unit tests for CalendarDay: click opens day detail only for current month;
* other-month cells do not call onDayClick and are non-interactive (aria-disabled).
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CalendarDay } from "./CalendarDay";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarDay", () => {
const defaultProps = {
dateKey: "2025-02-15",
dayOfMonth: 15,
isToday: false,
duties: [],
eventSummaries: [],
onDayClick: () => {},
};
beforeEach(() => {
resetAppStore();
});
it("calls onDayClick with dateKey and rect when clicked and isOtherMonth is false", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={false}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).toHaveBeenCalledTimes(1);
expect(onDayClick).toHaveBeenCalledWith(
"2025-02-15",
expect.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
top: expect.any(Number),
left: expect.any(Number),
})
);
});
it("does not call onDayClick when clicked and isOtherMonth is true", () => {
const onDayClick = vi.fn();
render(
<CalendarDay
{...defaultProps}
isOtherMonth={true}
onDayClick={onDayClick}
/>
);
const button = screen.getByRole("button", { name: /15/ });
fireEvent.click(button);
expect(onDayClick).not.toHaveBeenCalled();
});
it("sets aria-disabled on the button when isOtherMonth is true", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={true} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button).toHaveAttribute("aria-disabled", "true");
});
it("is not disabled for interaction when isOtherMonth is false", () => {
render(
<CalendarDay {...defaultProps} isOtherMonth={false} onDayClick={() => {}} />
);
const button = screen.getByRole("button", { name: /15/ });
expect(button.getAttribute("aria-disabled")).not.toBe("true");
});
});

View File

@@ -0,0 +1,123 @@
/**
* Single calendar day cell: date number and day indicators. Click opens day detail.
* Ported from webapp/js/calendar.js day cell rendering.
*/
"use client";
import React, { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { dateKeyToDDMM } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
import { DayIndicators } from "./DayIndicators";
export interface CalendarDayProps {
/** YYYY-MM-DD key for this day. */
dateKey: string;
/** Day of month (131) for display. */
dayOfMonth: number;
isToday: boolean;
isOtherMonth: boolean;
/** Duties overlapping this day (for indicators and tooltip). */
duties: DutyWithUser[];
/** External calendar event summaries for this day. */
eventSummaries: string[];
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
}
function CalendarDayInner({
dateKey,
dayOfMonth,
isToday,
isOtherMonth,
duties,
eventSummaries,
onDayClick,
}: CalendarDayProps) {
const { t } = useTranslation();
const { dutyList, unavailableList, vacationList } = useMemo(
() => ({
dutyList: duties.filter((d) => d.event_type === "duty"),
unavailableList: duties.filter((d) => d.event_type === "unavailable"),
vacationList: duties.filter((d) => d.event_type === "vacation"),
}),
[duties]
);
const hasEvent = eventSummaries.length > 0;
const showIndicator = !isOtherMonth;
const hasAny = duties.length > 0 || hasEvent;
const ariaParts: string[] = [dateKeyToDDMM(dateKey)];
if (hasAny && showIndicator) {
const counts: string[] = [];
if (dutyList.length) counts.push(`${dutyList.length} ${t("event_type.duty")}`);
if (unavailableList.length)
counts.push(`${unavailableList.length} ${t("event_type.unavailable")}`);
if (vacationList.length)
counts.push(`${vacationList.length} ${t("event_type.vacation")}`);
if (hasEvent) counts.push(t("hint.events"));
ariaParts.push(counts.join(", "));
} else {
ariaParts.push(t("aria.day_info"));
}
const ariaLabel = ariaParts.join("; ");
const content = (
<button
type="button"
aria-label={ariaLabel}
aria-disabled={isOtherMonth}
data-date={dateKey}
className={cn(
"relative flex w-full aspect-square min-h-8 min-w-0 flex-col items-center justify-start rounded-lg p-1 text-[0.85rem] transition-[background-color,transform] overflow-hidden",
"focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent",
isOtherMonth &&
"pointer-events-none opacity-40 bg-[var(--surface-muted-tint)] cursor-default",
!isOtherMonth && [
"bg-surface hover:bg-[var(--surface-hover-10)]",
"active:scale-[0.98] cursor-pointer",
isToday && "bg-today text-[var(--bg)] hover:bg-[var(--today-hover)]",
],
showIndicator && hasAny && "font-bold",
showIndicator &&
hasEvent &&
"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)]"
)}
onClick={(e) => {
if (isOtherMonth) return;
onDayClick(dateKey, e.currentTarget.getBoundingClientRect());
}}
>
<span className="num">{dayOfMonth}</span>
{showIndicator && (duties.length > 0 || hasEvent) && (
<DayIndicators
dutyCount={dutyList.length}
unavailableCount={unavailableList.length}
vacationCount={vacationList.length}
hasEvents={hasEvent}
isToday={isToday}
/>
)}
</button>
);
return content;
}
function arePropsEqual(prev: CalendarDayProps, next: CalendarDayProps): boolean {
return (
prev.dateKey === next.dateKey &&
prev.dayOfMonth === next.dayOfMonth &&
prev.isToday === next.isToday &&
prev.isOtherMonth === next.isOtherMonth &&
prev.duties === next.duties &&
prev.eventSummaries === next.eventSummaries &&
prev.onDayClick === next.onDayClick
);
}
export const CalendarDay = React.memo(CalendarDayInner, arePropsEqual);

View File

@@ -0,0 +1,88 @@
/**
* Unit tests for CalendarGrid: 42 cells, data-date, today class, month title in header.
* Ported from webapp/js/calendar.test.js renderCalendar.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { CalendarGrid } from "./CalendarGrid";
import { CalendarHeader } from "./CalendarHeader";
import { resetAppStore } from "@/test/test-utils";
describe("CalendarGrid", () => {
beforeEach(() => {
resetAppStore();
});
it("renders 42 cells (6 weeks)", () => {
const currentMonth = new Date(2025, 0, 1); // January 2025
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const cells = screen.getAllByRole("button", { name: /;/ });
expect(cells.length).toBe(42);
});
it("sets data-date on each cell to YYYY-MM-DD", () => {
const currentMonth = new Date(2025, 0, 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const grid = screen.getByRole("grid", { name: "Calendar" });
const buttons = grid.querySelectorAll('button[data-date]');
expect(buttons.length).toBe(42);
buttons.forEach((el) => {
const date = el.getAttribute("data-date");
expect(date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
});
it("adds today styling to cell matching today", () => {
const today = new Date();
const currentMonth = new Date(today.getFullYear(), today.getMonth(), 1);
render(
<CalendarGrid
currentMonth={currentMonth}
duties={[]}
calendarEvents={[]}
onDayClick={() => {}}
/>
);
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
const todayKey = `${year}-${month}-${day}`;
const todayCell = document.querySelector(`button[data-date="${todayKey}"]`);
expect(todayCell).toBeTruthy();
expect(todayCell?.className).toMatch(/today|bg-today/);
});
});
describe("CalendarHeader", () => {
beforeEach(() => {
resetAppStore();
});
it("sets month title from lang and given year/month", () => {
render(
<CalendarHeader
month={new Date(2025, 1, 1)}
onPrevMonth={() => {}}
onNextMonth={() => {}}
/>
);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent("February");
expect(heading).toHaveTextContent("2025");
});
});

View File

@@ -0,0 +1,93 @@
/**
* 6-week (42-cell) calendar grid starting from Monday. Composes CalendarDay cells.
* Ported from webapp/js/calendar.js renderCalendar.
*/
"use client";
import { useMemo } from "react";
import {
firstDayOfMonth,
getMonday,
localDateString,
} from "@/lib/date-utils";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { cn } from "@/lib/utils";
import { CalendarDay } from "./CalendarDay";
export interface CalendarGridProps {
/** Currently displayed month. */
currentMonth: Date;
/** All duties for the visible range (will be grouped by date). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when a day cell is clicked (opens day detail). Receives date key and cell rect for popover. */
onDayClick: (dateKey: string, anchorRect: DOMRect) => void;
className?: string;
}
const CELLS = 42;
export function CalendarGrid({
currentMonth,
duties,
calendarEvents,
onDayClick,
className,
}: CalendarGridProps) {
const dutiesByDateMap = useMemo(
() => dutiesByDate(duties),
[duties]
);
const calendarEventsByDateMap = useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const todayKey = localDateString(new Date());
const cells = useMemo(() => {
const first = firstDayOfMonth(currentMonth);
const start = getMonday(first);
const result: { date: Date; key: string; month: number }[] = [];
const d = new Date(start);
for (let i = 0; i < CELLS; i++) {
const key = localDateString(d);
result.push({ date: new Date(d), key, month: d.getMonth() });
d.setDate(d.getDate() + 1);
}
return result;
}, [currentMonth]);
return (
<div
className={cn(
"calendar-grid grid grid-cols-7 gap-1 mb-4 min-h-[var(--calendar-grid-min-height)]",
className
)}
role="grid"
aria-label="Calendar"
>
{cells.map(({ date, key, month }) => {
const isOtherMonth = month !== currentMonth.getMonth();
const dayDuties = dutiesByDateMap[key] ?? [];
const eventSummaries = calendarEventsByDateMap[key] ?? [];
return (
<div key={key} role="gridcell" className="min-h-0">
<CalendarDay
dateKey={key}
dayOfMonth={date.getDate()}
isToday={key === todayKey}
isOtherMonth={isOtherMonth}
duties={dayDuties}
eventSummaries={eventSummaries}
onDayClick={onDayClick}
/>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,147 @@
/**
* Calendar header: month title, prev/next navigation, weekday labels.
* Replaces the header from webapp index.html and calendar.js month title.
*/
"use client";
import { Button } from "@/components/ui/button";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
import {
ChevronLeft as ChevronLeftIcon,
ChevronRight as ChevronRightIcon,
RefreshCw as RefreshCwIcon,
} from "lucide-react";
export interface CalendarHeaderProps {
/** Currently displayed month (used for title). */
month: Date;
/** Whether month navigation is disabled (e.g. during loading). */
disabled?: boolean;
/** When true, show a compact loading spinner next to the month title (e.g. while fetching new month). */
isLoading?: boolean;
/** When provided and displayed month is not the current month, show a "Today" control that calls this. */
onGoToToday?: () => void;
/** When provided, show a refresh icon that calls this (e.g. to refetch month data). */
onRefresh?: () => void;
onPrevMonth: () => void;
onNextMonth: () => void;
className?: string;
}
const HeaderSpinner = () => (
<span
className="block size-4 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
);
function isCurrentMonth(month: Date): boolean {
const now = new Date();
return (
month.getFullYear() === now.getFullYear() &&
month.getMonth() === now.getMonth()
);
}
export function CalendarHeader({
month,
disabled = false,
isLoading = false,
onGoToToday,
onRefresh,
onPrevMonth,
onNextMonth,
className,
}: CalendarHeaderProps) {
const { t, monthName, weekdayLabels } = useTranslation();
const year = month.getFullYear();
const monthIndex = month.getMonth();
const labels = weekdayLabels();
const showToday = Boolean(onGoToToday) && !isCurrentMonth(month);
return (
<header className={cn("flex flex-col", className)}>
<div className="flex items-center justify-between mb-3">
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.prev_month")}
disabled={disabled}
onClick={onPrevMonth}
>
<ChevronLeftIcon className="size-5" aria-hidden />
</Button>
<div className="flex flex-col items-center gap-0.5">
<h1
className="m-0 flex items-center justify-center gap-2 text-[1.1rem] font-semibold sm:text-[1.25rem]"
aria-live="polite"
aria-atomic="true"
>
{monthName(monthIndex)} {year}
{isLoading && (
<span
role="status"
aria-live="polite"
aria-label={t("loading")}
className="flex items-center"
>
<HeaderSpinner />
</span>
)}
</h1>
{showToday && (
<button
type="button"
onClick={onGoToToday}
disabled={disabled}
className="text-[0.8rem] font-medium text-accent hover:underline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2 disabled:opacity-50"
aria-label={t("nav.today")}
>
{t("nav.today")}
</button>
)}
</div>
<div className="flex items-center gap-1">
{onRefresh && (
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.refresh")}
disabled={disabled || isLoading}
onClick={onRefresh}
>
<RefreshCwIcon
className={cn("size-5", isLoading && "motion-reduce:animate-none animate-spin")}
aria-hidden
/>
</Button>
)}
<Button
type="button"
variant="secondary"
size="icon"
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
aria-label={t("nav.next_month")}
disabled={disabled}
onClick={onNextMonth}
>
<ChevronRightIcon className="size-5" aria-hidden />
</Button>
</div>
</div>
<div className="grid grid-cols-7 gap-0.5 mb-1.5 text-center text-[0.75rem] text-muted">
{labels.map((label, i) => (
<span key={i} aria-hidden>
{label}
</span>
))}
</div>
</header>
);
}

View File

@@ -0,0 +1,70 @@
/**
* Unit tests for DayIndicators: rounding is position-based (first / last / only child),
* not by indicator type, so multiple segments form one pill.
*/
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { DayIndicators } from "./DayIndicators";
const baseProps = {
dutyCount: 0,
unavailableCount: 0,
vacationCount: 0,
hasEvents: false,
};
describe("DayIndicators", () => {
it("renders nothing when no indicators", () => {
const { container } = render(<DayIndicators {...baseProps} />);
expect(container.firstChild).toBeNull();
});
it("renders one segment with only-child rounding (e.g. vacation only)", () => {
const { container } = render(
<DayIndicators {...baseProps} vacationCount={1} />
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain("[&>:only-child]:rounded-full");
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(1);
});
it("renders two segments with positional rounding (duty + vacation)", () => {
const { container } = render(
<DayIndicators {...baseProps} dutyCount={1} vacationCount={1} />
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain(
"[&>:first-child:not(:only-child)]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child:not(:only-child)]:rounded-r-[3px]"
);
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(2);
});
it("renders three segments with first left-rounded, last right-rounded (duty + unavailable + vacation)", () => {
const { container } = render(
<DayIndicators
{...baseProps}
dutyCount={1}
unavailableCount={1}
vacationCount={1}
/>
);
const wrapper = container.querySelector("[aria-hidden]");
expect(wrapper).toBeInTheDocument();
expect(wrapper?.className).toContain(
"[&>:first-child:not(:only-child)]:rounded-l-[3px]"
);
expect(wrapper?.className).toContain(
"[&>:last-child:not(:only-child)]:rounded-r-[3px]"
);
const spans = wrapper?.querySelectorAll("span");
expect(spans).toHaveLength(3);
});
});

View File

@@ -0,0 +1,66 @@
/**
* Colored dots for calendar day: duty (green), unavailable (amber), vacation (blue), events (accent).
* Ported from webapp calendar day-indicator markup and markers.css.
*
* Rounding is position-based (first / last / only child), not by indicator type, so multiple
* segments form one "pill": only the left and right ends are rounded.
*/
import { cn } from "@/lib/utils";
export interface DayIndicatorsProps {
/** Number of duty slots this day. */
dutyCount: number;
/** Number of unavailable slots. */
unavailableCount: number;
/** Number of vacation slots. */
vacationCount: number;
/** Whether the day has external calendar events (e.g. holiday). */
hasEvents: boolean;
/** When true (e.g. today cell), use darker dots for contrast. */
isToday?: boolean;
className?: string;
}
export function DayIndicators({
dutyCount,
unavailableCount,
vacationCount,
hasEvents,
isToday = false,
className,
}: DayIndicatorsProps) {
const hasAny = dutyCount > 0 || unavailableCount > 0 || vacationCount > 0 || hasEvents;
if (!hasAny) return null;
const dotClass = (variant: "duty" | "unavailable" | "vacation" | "events") =>
cn(
"min-w-0 flex-1 h-1 max-h-1.5",
variant === "duty" && "bg-duty",
variant === "unavailable" && "bg-unavailable",
variant === "vacation" && "bg-vacation",
variant === "events" && "bg-accent",
isToday && variant === "duty" && "bg-[var(--indicator-today-duty)]",
isToday && variant === "unavailable" && "bg-[var(--indicator-today-unavailable)]",
isToday && variant === "vacation" && "bg-[var(--indicator-today-vacation)]",
isToday && variant === "events" && "bg-[var(--indicator-today-events)]"
);
return (
<div
className={cn(
"flex w-[65%] justify-center gap-0.5 mt-1.5",
"[&>:only-child]:h-1.5 [&>:only-child]:min-w-[6px] [&>:only-child]:max-w-[6px] [&>:only-child]:rounded-full",
"[&>:first-child:not(:only-child)]:rounded-l-[3px]",
"[&>:last-child:not(:only-child)]:rounded-r-[3px]",
className
)}
aria-hidden
>
{dutyCount > 0 && <span className={dotClass("duty")} />}
{unavailableCount > 0 && <span className={dotClass("unavailable")} />}
{vacationCount > 0 && <span className={dotClass("vacation")} />}
{hasEvents && <span className={dotClass("events")} />}
</div>
);
}

View File

@@ -0,0 +1,12 @@
/**
* Calendar components and data helpers.
*/
export { CalendarGrid } from "./CalendarGrid";
export { CalendarHeader } from "./CalendarHeader";
export { CalendarDay } from "./CalendarDay";
export { DayIndicators } from "./DayIndicators";
export type { DayIndicatorsProps } from "./DayIndicators";
export type { CalendarGridProps } from "./CalendarGrid";
export type { CalendarHeaderProps } from "./CalendarHeader";
export type { CalendarDayProps } from "./CalendarDay";

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for ContactLinks: phone/Telegram display, labels, layout.
* Ported from webapp/js/contactHtml.test.js buildContactLinksHtml.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ContactLinks } from "./ContactLinks";
import { resetAppStore } from "@/test/test-utils";
describe("ContactLinks", () => {
beforeEach(() => {
resetAppStore();
});
it("returns null when phone and username are missing", () => {
const { container } = render(
<ContactLinks phone={null} username={null} />
);
expect(container.firstChild).toBeNull();
});
it("renders phone only with label and tel: link", () => {
render(<ContactLinks phone="+79991234567" username={null} showLabels />);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(screen.getByText(/Phone/i)).toBeInTheDocument();
});
it("displays phone formatted for Russian numbers", () => {
render(<ContactLinks phone="79146522209" username={null} />);
expect(screen.getByText(/\+7 914 652-22-09/)).toBeInTheDocument();
});
it("renders username only with label and t.me link", () => {
render(<ContactLinks phone={null} username="alice_dev" showLabels />);
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
});
it("renders both phone and username with labels", () => {
render(
<ContactLinks
phone="+79001112233"
username="bob"
showLabels
/>
);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/\+7 900 111-22-33/)).toBeInTheDocument();
expect(screen.getByText(/@bob/)).toBeInTheDocument();
});
it("strips leading @ from username and displays with @", () => {
render(<ContactLinks phone={null} username="@alice" />);
const link = document.querySelector('a[href*="t.me/alice"]');
expect(link).toBeInTheDocument();
expect(link?.textContent).toContain("@alice");
});
});

View File

@@ -0,0 +1,145 @@
/**
* Contact links (phone, Telegram) for duty cards and day detail.
* Ported from webapp/js/contactHtml.js buildContactLinksHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatPhoneDisplay } from "@/lib/phone-format";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Phone as PhoneIcon, Send as TelegramIcon } from "lucide-react";
export interface ContactLinksProps {
phone?: string | null;
username?: string | null;
layout?: "inline" | "block";
showLabels?: boolean;
/** Optional label for aria-label on links (e.g. duty holder name for "Call …", "Message … on Telegram"). */
contextLabel?: string;
className?: string;
}
const linkClass =
"text-accent hover:underline focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2";
/**
* Renders phone (tel:) and Telegram (t.me) links. Used on flip card back and day detail.
*/
export function ContactLinks({
phone,
username,
layout = "inline",
showLabels = true,
contextLabel,
className,
}: ContactLinksProps) {
const { t } = useTranslation();
const hasPhone = Boolean(phone && String(phone).trim());
const rawUsername = username && String(username).trim();
const cleanUsername = rawUsername ? rawUsername.replace(/^@+/, "") : "";
const hasUsername = Boolean(cleanUsername);
if (!hasPhone && !hasUsername) return null;
const ariaCall = contextLabel
? t("contact.aria_call", { name: contextLabel })
: t("contact.phone");
const ariaTelegram = contextLabel
? t("contact.aria_telegram", { name: contextLabel })
: t("contact.telegram");
if (layout === "block") {
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
>
<a href={`tel:${String(phone).trim()}`} aria-label={ariaCall}>
<PhoneIcon className="size-5" aria-hidden />
<span>{formatPhoneDisplay(phone!)}</span>
</a>
</Button>
)}
{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
>
<a
href={`https://t.me/${encodeURIComponent(cleanUsername)}`}
target="_blank"
rel="noopener noreferrer"
aria-label={ariaTelegram}
>
<TelegramIcon className="size-5" aria-hidden />
<span>@{cleanUsername}</span>
</a>
</Button>
)}
</div>
);
}
const parts: React.ReactNode[] = [];
if (hasPhone) {
const displayPhone = formatPhoneDisplay(phone!);
parts.push(
showLabels ? (
<span key="phone">
{t("contact.phone")}:{" "}
<a
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
</span>
) : (
<a
key="phone"
href={`tel:${String(phone).trim()}`}
className={linkClass}
aria-label={ariaCall}
>
{displayPhone}
</a>
)
);
}
if (hasUsername) {
const href = `https://t.me/${encodeURIComponent(cleanUsername)}`;
const link = (
<a
key="tg"
href={href}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
aria-label={ariaTelegram}
>
@{cleanUsername}
</a>
);
parts.push(showLabels ? <span key="tg">{t("contact.telegram")}: {link}</span> : link);
}
return (
<div className={cn("text-sm text-muted-foreground flex flex-wrap items-center gap-x-1", className)}>
{parts.map((p, i) => (
<span key={i} className="inline-flex items-center gap-x-1">
{i > 0 && <span aria-hidden className="text-muted-foreground">·</span>}
{p}
</span>
))}
</div>
);
}

View File

@@ -0,0 +1,6 @@
/**
* Contact links component.
*/
export { ContactLinks } from "./ContactLinks";
export type { ContactLinksProps } from "./ContactLinks";

View File

@@ -0,0 +1,84 @@
/**
* Unit tests for CurrentDutyView: no-duty message, duty card with contacts.
* Ported from webapp/js/currentDuty.test.js renderCurrentDutyContent / showCurrentDutyView.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CurrentDutyView } from "./CurrentDutyView";
import { resetAppStore } from "@/test/test-utils";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: () => ({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
}),
}));
vi.mock("@/lib/api", () => ({
fetchDuties: vi.fn().mockResolvedValue([]),
AccessDeniedError: class AccessDeniedError extends Error {
serverDetail?: string;
constructor(m: string, d?: string) {
super(m);
this.serverDetail = d;
}
},
}));
describe("CurrentDutyView", () => {
beforeEach(() => {
resetAppStore();
vi.clearAllMocks();
});
it("shows loading then no-duty message when no active duty", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByText(/Back to calendar|Назад к календарю/i)).toBeInTheDocument();
});
it("back button calls onBack when clicked", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
const buttons = screen.getAllByRole("button", { name: /Back to calendar|Назад к календарю/i });
fireEvent.click(buttons[buttons.length - 1]);
expect(onBack).toHaveBeenCalled();
});
it("shows Close button when openedFromPin is true", async () => {
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} openedFromPin={true} />);
await screen.findByText(/No one is on duty|Сейчас никто не дежурит/i, {}, { timeout: 3000 });
expect(screen.getByRole("button", { name: /Close|Закрыть/i })).toBeInTheDocument();
expect(screen.queryByText(/Back to calendar|Назад к календарю/i)).not.toBeInTheDocument();
});
it("shows contact info not set when duty has no phone or username", async () => {
const { fetchDuties } = await import("@/lib/api");
const now = new Date();
const start = new Date(now.getTime() - 60 * 60 * 1000); // 1 hour ago
const end = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now
const dutyNoContacts = {
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([dutyNoContacts]);
const onBack = vi.fn();
render(<CurrentDutyView onBack={onBack} />);
await screen.findByText("Test User", {}, { timeout: 3000 });
expect(
screen.getByText(/Contact info not set|Контактные данные не указаны/i)
).toBeInTheDocument();
vi.mocked(fetchDuties).mockResolvedValue([]);
});
});

View File

@@ -0,0 +1,323 @@
/**
* Current duty view: full-screen card when opened via Mini App deep link (startapp=duty).
* Fetches today's duties, finds the active one, shows name, shift, auto-updating remaining time,
* and contact links. Integrates with Telegram BackButton.
* Ported from webapp/js/currentDuty.js.
*/
"use client";
import { useEffect, useState, useCallback } from "react";
import { backButton, closeMiniApp } from "@telegram-apps/sdk-react";
import { Calendar } from "lucide-react";
import { useTranslation } from "@/i18n/use-translation";
import { useAppStore } from "@/store/app-store";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { fetchDuties, AccessDeniedError } from "@/lib/api";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { getRemainingTime, findCurrentDuty } from "@/lib/current-duty";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import type { DutyWithUser } from "@/types";
export interface CurrentDutyViewProps {
/** Called when user taps Back (in-app button or Telegram BackButton). */
onBack: () => void;
/** True when opened via pin button (startParam=duty). Shows Close instead of Back to calendar. */
openedFromPin?: boolean;
}
type ViewState = "loading" | "error" | "ready";
/**
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
*/
export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyViewProps) {
const { t } = useTranslation();
const lang = useAppStore((s) => s.lang);
const { initDataRaw } = useTelegramAuth();
const [state, setState] = useState<ViewState>("loading");
const [duty, setDuty] = useState<DutyWithUser | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
const loadTodayDuties = useCallback(
async (signal?: AbortSignal | null) => {
const today = new Date();
const from = localDateString(today);
const to = from;
const initData = initDataRaw ?? "";
try {
const duties = await fetchDuties(from, to, initData, lang, signal);
if (signal?.aborted) return;
const active = findCurrentDuty(duties);
setDuty(active);
setState("ready");
if (active) {
setRemaining(getRemainingTime(active.end_at));
} else {
setRemaining(null);
}
} catch (e) {
if (signal?.aborted) return;
setState("error");
const msg =
e instanceof AccessDeniedError && e.serverDetail
? e.serverDetail
: t("error_generic");
setErrorMessage(msg);
setDuty(null);
setRemaining(null);
}
},
[initDataRaw, lang, t]
);
// Fetch today's duties on mount; abort on unmount to avoid setState after unmount.
useEffect(() => {
const controller = new AbortController();
loadTodayDuties(controller.signal);
return () => controller.abort();
}, [loadTodayDuties]);
// Signal Telegram to hide loading when this view is ready (or error).
useEffect(() => {
if (state !== "loading") {
callMiniAppReadyOnce();
}
}, [state]);
// Auto-update remaining time every second when there is an active duty.
useEffect(() => {
if (!duty) return;
const interval = setInterval(() => {
setRemaining(getRemainingTime(duty.end_at));
}, 1000);
return () => clearInterval(interval);
}, [duty]);
// Telegram BackButton: show on mount, hide on unmount, handle click.
useEffect(() => {
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(onBack);
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [onBack]);
const handleBack = () => {
onBack();
};
const handleClose = () => {
if (closeMiniApp.isAvailable()) {
closeMiniApp();
} else {
onBack();
}
};
const primaryButtonLabel = openedFromPin ? t("current_duty.close") : t("current_duty.back");
const primaryButtonAriaLabel = openedFromPin
? t("current_duty.close")
: t("current_duty.back");
const handlePrimaryAction = openedFromPin ? handleClose : handleBack;
if (state === "loading") {
return (
<div
className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4"
role="status"
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>
</div>
);
}
if (state === "error") {
const handleRetry = () => {
setState("loading");
loadTodayDuties();
};
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="w-full max-w-[var(--max-width-app)]">
<CardContent className="pt-6">
<p className="text-error">{errorMessage}</p>
</CardContent>
<CardFooter className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button
variant="default"
onClick={handleRetry}
aria-label={t("error.retry")}
>
{t("error.retry")}
</Button>
<Button
variant="outline"
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
if (!duty) {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card className="current-duty-card--no-duty w-full max-w-[var(--max-width-app)] border-t-4 border-t-muted">
<CardHeader>
<CardTitle>{t("current_duty.title")}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
<span
className="flex items-center justify-center text-muted-foreground"
aria-hidden
>
<Calendar className="size-12" strokeWidth={1.5} />
</span>
<p className="text-center text-muted-foreground">
{t("current_duty.no_duty")}
</p>
</CardContent>
<CardFooter>
<Button onClick={handlePrimaryAction} aria-label={primaryButtonAriaLabel}>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
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", {
hours: String(rem.hours),
minutes: String(rem.minutes),
});
const endsAtStr = t("current_duty.ends_at", { time: endTime });
const displayTz =
typeof window !== "undefined" &&
(window as unknown as { __DT_TZ?: string }).__DT_TZ;
const shiftLabel = displayTz
? t("current_duty.shift_tz", { tz: displayTz })
: t("current_duty.shift_local");
const hasContacts =
Boolean(duty.phone && String(duty.phone).trim()) ||
Boolean(duty.username && String(duty.username).trim());
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4 px-4">
<Card
className="current-duty-card w-full max-w-[var(--max-width-app)] border-t-4 border-t-duty animate-in fade-in-0 slide-in-from-bottom-4 duration-300 motion-reduce:animate-none motion-reduce:duration-0"
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>
<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>
<div
className="rounded-lg bg-duty/10 px-3 py-2 text-sm font-medium text-foreground"
aria-live="polite"
aria-atomic="true"
>
{remainingStr}
</div>
<p className="text-sm text-muted-foreground">{endsAtStr}</p>
{hasContacts ? (
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="block"
showLabels={true}
contextLabel={duty.full_name ?? undefined}
/>
) : (
<p className="text-sm text-muted-foreground">
{t("current_duty.contact_info_not_set")}
</p>
)}
</CardContent>
<CardFooter>
<Button
onClick={handlePrimaryAction}
aria-label={primaryButtonAriaLabel}
>
{primaryButtonLabel}
</Button>
</CardFooter>
</Card>
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { CurrentDutyView } from "./CurrentDutyView";
export type { CurrentDutyViewProps } from "./CurrentDutyView";

View File

@@ -0,0 +1,77 @@
/**
* Unit tests for DayDetailContent: sorts duties by start_at, includes contact info.
* Ported from webapp/js/dayDetail.test.js buildDayDetailContent.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DayDetailContent } from "./DayDetailContent";
import { resetAppStore } from "@/test/test-utils";
import type { DutyWithUser } from "@/types";
function duty(
full_name: string,
start_at: string,
end_at: string,
extra: Partial<DutyWithUser> = {}
): DutyWithUser {
return {
id: 1,
user_id: 1,
full_name,
start_at,
end_at,
event_type: "duty",
phone: null,
username: null,
...extra,
};
}
describe("DayDetailContent", () => {
beforeEach(() => {
resetAppStore();
});
it("sorts duty list by start_at when input order is wrong", () => {
const dateKey = "2025-02-25";
const duties = [
duty("Петров", "2025-02-25T14:00:00Z", "2025-02-25T18:00:00Z", { id: 2 }),
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T14:00:00Z", { id: 1 }),
];
render(
<DayDetailContent
dateKey={dateKey}
duties={duties}
eventSummaries={[]}
/>
);
expect(screen.getByText("Иванов")).toBeInTheDocument();
expect(screen.getByText("Петров")).toBeInTheDocument();
const body = document.body.innerHTML;
const ivanovPos = body.indexOf("Иванов");
const petrovPos = body.indexOf("Петров");
expect(ivanovPos).toBeLessThan(petrovPos);
});
it("includes contact info (phone, username) for duty entries when present", () => {
const dateKey = "2025-03-01";
const duties = [
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
phone: "+79991234567",
username: "alice_dev",
}),
];
render(
<DayDetailContent
dateKey={dateKey}
duties={duties}
eventSummaries={[]}
/>
);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
expect(screen.getByText(/alice_dev/)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,255 @@
/**
* Day detail panel: shadcn Popover on desktop (>=640px), Sheet (bottom) on mobile.
* Renders DayDetailContent; anchor for popover is a virtual element at the clicked cell rect.
* Owns anchor rect state; parent opens via ref.current.openWithRect(dateKey, rect).
*/
"use client";
import * as React from "react";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useTranslation } from "@/i18n/use-translation";
import { dutiesByDate, calendarEventsByDate } from "@/lib/calendar-data";
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
import { DayDetailContent } from "./DayDetailContent";
import type { CalendarEvent, DutyWithUser } from "@/types";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/store/app-store";
/** Empty state for day detail: date and "no duties or events" message. */
function DayDetailEmpty({ dateKey }: { dateKey: string }) {
const { t } = useTranslation();
const todayKey = localDateString(new Date());
const ddmm = dateKeyToDDMM(dateKey);
const title =
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
return (
<div className="flex flex-col gap-4">
<h2
id="day-detail-title"
className="text-[1.1rem] font-semibold leading-tight m-0"
>
{title}
</h2>
<p className="text-sm text-muted-foreground m-0">
{t("day_detail.no_events")}
</p>
</div>
);
}
export interface DayDetailHandle {
/** Open the panel for the given day with popover anchored at the given rect. */
openWithRect: (dateKey: string, anchorRect: DOMRect) => void;
}
export interface DayDetailProps {
/** All duties for the visible range (will be filtered by selectedDay). */
duties: DutyWithUser[];
/** All calendar events for the visible range. */
calendarEvents: CalendarEvent[];
/** Called when the panel should close. */
onClose: () => void;
className?: string;
}
/**
* Virtual anchor: invisible div at the given rect so Popover positions relative to it.
*/
function VirtualAnchor({
rect,
className,
}: {
rect: DOMRect;
className?: string;
}) {
return (
<div
className={cn("pointer-events-none fixed z-0", className)}
style={{
left: rect.left,
top: rect.bottom,
width: rect.width,
height: 1,
}}
aria-hidden
/>
);
}
export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
function DayDetail({ duties, calendarEvents, onClose, className }, ref) {
const isDesktop = useIsDesktop();
const { t } = useTranslation();
const selectedDay = useAppStore((s) => s.selectedDay);
const setSelectedDay = useAppStore((s) => s.setSelectedDay);
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
const [exiting, setExiting] = React.useState(false);
const open = selectedDay !== null;
React.useImperativeHandle(
ref,
() => ({
openWithRect(dateKey: string, rect: DOMRect) {
setSelectedDay(dateKey);
setAnchorRect(rect);
},
}),
[setSelectedDay]
);
const handleClose = React.useCallback(() => {
setSelectedDay(null);
setAnchorRect(null);
setExiting(false);
onClose();
}, [setSelectedDay, onClose]);
/** Start close animation; actual unmount happens in onCloseAnimationEnd (or fallback timeout). */
const requestClose = React.useCallback(() => {
setExiting(true);
}, []);
// Fallback: if onAnimationEnd never fires (e.g. reduced motion), close after animation duration
React.useEffect(() => {
if (!exiting) return;
const fallback = window.setTimeout(() => {
handleClose();
}, 320);
return () => window.clearTimeout(fallback);
}, [exiting, handleClose]);
const dutiesByDateMap = React.useMemo(
() => dutiesByDate(duties),
[duties]
);
const eventsByDateMap = React.useMemo(
() => calendarEventsByDate(calendarEvents),
[calendarEvents]
);
const dayDuties = selectedDay ? dutiesByDateMap[selectedDay] ?? [] : [];
const dayEvents = selectedDay ? eventsByDateMap[selectedDay] ?? [] : [];
const hasContent = dayDuties.length > 0 || dayEvents.length > 0;
// Close popover/sheet on window resize so anchor position does not become stale.
React.useEffect(() => {
if (!open) return;
const onResize = () => handleClose();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [open, handleClose]);
const content =
selectedDay &&
(hasContent ? (
<DayDetailContent
dateKey={selectedDay}
duties={dayDuties}
eventSummaries={dayEvents}
/>
) : (
<DayDetailEmpty dateKey={selectedDay} />
));
if (!open || !selectedDay) return null;
const panelClassName =
"max-w-[min(360px,calc(100vw-24px))] max-h-[70vh] overflow-auto bg-surface text-[var(--text)] rounded-xl shadow-lg p-4 pt-9";
const closeButton = (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={requestClose}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
);
const renderSheet = (withHandle: boolean) => (
<Sheet
open={!exiting && open}
onOpenChange={(o) => !o && requestClose()}
>
<SheetContent
side="bottom"
className={cn(
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh]",
className
)}
showCloseButton={false}
onCloseAnimationEnd={handleClose}
>
<div className="relative px-4">
{closeButton}
{withHandle && (
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
)}
<SheetHeader className="p-0">
<SheetTitle id="day-detail-sheet-title" className="sr-only">
{selectedDay}
</SheetTitle>
</SheetHeader>
{content}
</div>
</SheetContent>
</Sheet>
);
if (isDesktop === true && anchorRect != null) {
return (
<Popover open={open} onOpenChange={(o) => !o && handleClose()}>
<PopoverAnchor asChild>
<VirtualAnchor rect={anchorRect} />
</PopoverAnchor>
<PopoverContent
side="bottom"
sideOffset={8}
align="center"
className={cn(panelClassName, "relative", className)}
onCloseAutoFocus={(e) => e.preventDefault()}
onEscapeKeyDown={handleClose}
>
{closeButton}
{content}
</PopoverContent>
</Popover>
);
}
return renderSheet(true);
}
);

View File

@@ -0,0 +1,195 @@
/**
* Shared content for day detail: title and sections (duty, unavailable, vacation, events).
* Ported from webapp/js/dayDetail.js buildDayDetailContent.
*/
"use client";
import { useMemo } from "react";
import { useTranslation } from "@/i18n/use-translation";
import { localDateString, dateKeyToDDMM } from "@/lib/date-utils";
import { getDutyMarkerRows } from "@/lib/duty-marker-rows";
import { ContactLinks } from "@/components/contact";
import type { DutyWithUser } from "@/types";
import { cn } from "@/lib/utils";
const NBSP = "\u00a0";
export interface DayDetailContentProps {
/** YYYY-MM-DD key for the day. */
dateKey: string;
/** Duties overlapping this day. */
duties: DutyWithUser[];
/** Calendar event summary strings for this day. */
eventSummaries: string[];
className?: string;
}
export function DayDetailContent({
dateKey,
duties,
eventSummaries,
className,
}: DayDetailContentProps) {
const { t } = useTranslation();
const todayKey = localDateString(new Date());
const ddmm = dateKeyToDDMM(dateKey);
const title =
dateKey === todayKey ? t("duty.today") + ", " + ddmm : ddmm;
const fromLabel = t("hint.from");
const toLabel = t("hint.to");
const dutyList = useMemo(
() =>
duties
.filter((d) => d.event_type === "duty")
.sort(
(a, b) =>
new Date(a.start_at || 0).getTime() -
new Date(b.start_at || 0).getTime()
),
[duties]
);
const unavailableList = useMemo(
() => duties.filter((d) => d.event_type === "unavailable"),
[duties]
);
const vacationList = useMemo(
() => duties.filter((d) => d.event_type === "vacation"),
[duties]
);
const dutyRows = useMemo(() => {
const hasTimes = dutyList.some((it) => it.start_at || it.end_at);
return hasTimes
? getDutyMarkerRows(dutyList, dateKey, NBSP, fromLabel, toLabel)
: dutyList.map((d) => ({
id: d.id,
timePrefix: "",
fullName: d.full_name ?? "",
phone: d.phone,
username: d.username,
}));
}, [dutyList, dateKey, fromLabel, toLabel]);
const uniqueUnavailable = useMemo(
() => [
...new Set(
unavailableList.map((d) => d.full_name ?? "").filter(Boolean)
),
],
[unavailableList]
);
const uniqueVacation = useMemo(
() => [
...new Set(vacationList.map((d) => d.full_name ?? "").filter(Boolean)),
],
[vacationList]
);
const summaries = eventSummaries ?? [];
return (
<div className={cn("flex flex-col gap-4", className)}>
<h2
id="day-detail-title"
className="text-[1.1rem] font-semibold leading-tight m-0"
>
{title}
</h2>
<div className="flex flex-col gap-4">
{dutyList.length > 0 && (
<section
className="day-detail-section-duty"
aria-labelledby="day-detail-duty-heading"
>
<h3
id="day-detail-duty-heading"
className="text-[0.8rem] font-semibold text-duty m-0 mb-1"
>
{t("event_type.duty")}
</h3>
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-2.5 [&_li]:flex [&_li]:flex-col [&_li]:gap-1">
{dutyRows.map((r) => (
<li key={r.id}>
{r.timePrefix && (
<span className="text-muted-foreground">{r.timePrefix} </span>
)}
<span className="font-semibold">{r.fullName}</span>
<ContactLinks
phone={r.phone}
username={r.username}
layout="inline"
showLabels={true}
className="text-[0.85rem] mt-0.5"
/>
</li>
))}
</ul>
</section>
)}
{uniqueUnavailable.length > 0 && (
<section
className="day-detail-section-unavailable"
aria-labelledby="day-detail-unavailable-heading"
>
<h3
id="day-detail-unavailable-heading"
className="text-[0.8rem] font-semibold text-unavailable m-0 mb-1"
>
{t("event_type.unavailable")}
</h3>
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{uniqueUnavailable.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</section>
)}
{uniqueVacation.length > 0 && (
<section
className="day-detail-section-vacation"
aria-labelledby="day-detail-vacation-heading"
>
<h3
id="day-detail-vacation-heading"
className="text-[0.8rem] font-semibold text-vacation m-0 mb-1"
>
{t("event_type.vacation")}
</h3>
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{uniqueVacation.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</section>
)}
{summaries.length > 0 && (
<section
className="day-detail-section-events"
aria-labelledby="day-detail-events-heading"
>
<h3
id="day-detail-events-heading"
className="text-[0.8rem] font-semibold text-accent m-0 mb-1"
>
{t("hint.events")}
</h3>
<ul className="list-[disc] pl-5 m-0 text-[0.9rem] leading-snug space-y-1">
{summaries.map((s) => (
<li key={String(s)}>{String(s)}</li>
))}
</ul>
</section>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
export { DayDetail } from "./DayDetail";
export { DayDetailContent } from "./DayDetailContent";
export type { DayDetailContentProps } from "./DayDetailContent";
export type { DayDetailHandle, DayDetailProps } from "./DayDetail";

View File

@@ -0,0 +1,80 @@
/**
* Single duty row: event type label, name, time range.
* Used inside timeline cards and day detail. Ported from webapp/js/dutyList.js dutyItemHtml.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { formatHHMM, formatDateKey } from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import type { DutyWithUser } from "@/types";
export interface DutyItemProps {
duty: DutyWithUser;
/** Override type label (e.g. "On duty now"). */
typeLabelOverride?: string;
/** Show "until HH:MM" instead of full range (for current duty). */
showUntilEnd?: boolean;
/** Extra class, e.g. for current duty highlight. */
isCurrent?: boolean;
className?: string;
}
const borderByType = {
duty: "border-l-duty",
unavailable: "border-l-unavailable",
vacation: "border-l-vacation",
} as const;
/**
* Renders type badge, name, and time. Timeline cards use event_type for border color.
*/
export function DutyItem({
duty,
typeLabelOverride,
showUntilEnd = false,
isCurrent = false,
className,
}: DutyItemProps) {
const { t } = useTranslation();
const typeLabel =
typeLabelOverride ?? t(`event_type.${duty.event_type || "duty"}`);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
let timeOrRange: string;
if (showUntilEnd && duty.event_type === "duty") {
timeOrRange = t("duty.until", { time: formatHHMM(duty.end_at) });
} else if (duty.event_type === "vacation" || duty.event_type === "unavailable") {
const startStr = formatDateKey(duty.start_at);
const endStr = formatDateKey(duty.end_at);
timeOrRange = startStr === endStr ? startStr : `${startStr} ${endStr}`;
} else {
timeOrRange = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
}
return (
<div
className={cn(
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2",
"border-l-[3px] shadow-sm",
"min-h-0",
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
className
)}
data-slot="duty-item"
>
<span className="text-xs text-muted col-span-1 row-start-1">
{typeLabel}
</span>
<span className="font-semibold min-w-0 col-span-1 row-start-2 col-start-1">
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted col-span-1 row-start-3 col-start-1">
{timeOrRange}
</span>
</div>
);
}

View File

@@ -0,0 +1,66 @@
/**
* Unit tests for DutyList: renders timeline, flip card with contacts, duty items.
* Ported from webapp/js/dutyList.test.js.
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { DutyList } from "./DutyList";
import { useAppStore } from "@/store/app-store";
import { resetAppStore } from "@/test/test-utils";
import type { DutyWithUser } from "@/types";
function duty(
full_name: string,
start_at: string,
end_at: string,
extra: Partial<DutyWithUser> = {}
): DutyWithUser {
return {
id: 1,
user_id: 1,
full_name,
start_at,
end_at,
event_type: "duty",
phone: null,
username: null,
...extra,
};
}
describe("DutyList", () => {
beforeEach(() => {
resetAppStore();
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
});
it("renders no duties message when duties empty", () => {
useAppStore.getState().setDuties([]);
render(<DutyList />);
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
});
it("renders duty with full_name and time range", () => {
useAppStore.getState().setDuties([
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
render(<DutyList />);
expect(screen.getByText("Иванов")).toBeInTheDocument();
});
it("renders flip card with contact links when phone or username present", () => {
useAppStore.getState().setDuties([
duty("Alice", "2025-03-01T09:00:00Z", "2025-03-01T17:00:00Z", {
phone: "+79991234567",
username: "alice_dev",
}),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
render(<DutyList />);
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();
expect(document.querySelector('a[href*="t.me"]')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,256 @@
/**
* Duty timeline list for the current month: dates, track line, flip cards.
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
*/
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTranslation } from "@/i18n/use-translation";
import {
localDateString,
firstDayOfMonth,
lastDayOfMonth,
dateKeyToDDMM,
} from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import { Skeleton } from "@/components/ui/skeleton";
import { DutyTimelineCard } from "./DutyTimelineCard";
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
const SCROLL_OVERLAP_PX = 14;
/**
* Skeleton placeholder for duty list (e.g. when loading a new month).
* Shows 4 card-shaped placeholders in timeline layout.
*/
export function DutyListSkeleton({ className }: { className?: string }) {
return (
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
<div
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
aria-hidden
/>
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<Skeleton className="h-14 w-12 rounded" />
<span className="min-w-0" aria-hidden />
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
</div>
))}
</div>
);
}
export interface DutyListProps {
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
scrollMarginTop?: number;
className?: string;
}
/**
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
*/
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const { t } = useTranslation();
const listRef = useRef<HTMLDivElement>(null);
const { currentMonth, duties } = useAppStore(
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties }))
);
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
const filteredList = duties.filter((d) => d.event_type === "duty");
const todayKey = localDateString(new Date());
const firstKey = localDateString(firstDayOfMonth(currentMonth));
const lastKey = localDateString(lastDayOfMonth(currentMonth));
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
const dateSet = new Set<string>();
filteredList.forEach((d) =>
dateSet.add(localDateString(new Date(d.start_at)))
);
if (showTodayInMonth) dateSet.add(todayKey);
const datesList = Array.from(dateSet).sort();
const byDate: Record<string, typeof filteredList> = {};
datesList.forEach((date) => {
byDate[date] = filteredList
.filter((d) => localDateString(new Date(d.start_at)) === date)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
});
return {
filtered: filteredList,
dates: datesList,
dutiesByDateKey: byDate,
};
}, [currentMonth, duties]);
const todayKey = localDateString(new Date());
const [now, setNow] = useState(() => new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id);
}, []);
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
const scrolledForMonthRef = useRef<string | null>(null);
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
const prevMonthKeyRef = useRef<string>(monthKey);
useEffect(() => {
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
scrolledForMonthRef.current = null;
prevScrollMarginTopRef.current = scrollMarginTop;
}
if (prevMonthKeyRef.current !== monthKey) {
scrolledForMonthRef.current = null;
prevMonthKeyRef.current = monthKey;
}
const el = listRef.current;
if (!el) return;
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
const target = currentCard ?? todayBlock;
if (!target || scrolledForMonthRef.current === monthKey) return;
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
const scrollTo = () => {
const rect = target.getBoundingClientRect();
const scrollTop = window.scrollY + rect.top - effectiveMargin;
window.scrollTo({ top: scrollTop, behavior: "smooth" });
scrolledForMonthRef.current = monthKey;
};
requestAnimationFrame(() => {
requestAnimationFrame(scrollTo);
});
}, [filtered, dates.length, scrollMarginTop, monthKey]);
if (filtered.length === 0) {
return (
<div className={cn("flex flex-col gap-1", className)}>
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
</div>
);
}
return (
<div ref={listRef} className={className}>
<div className="duty-timeline relative text-[0.9rem]">
{/* Vertical track line */}
<div
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
aria-hidden
/>
{dates.map((date) => {
const isToday = date === todayKey;
const dateLabel = dateKeyToDDMM(date);
const dayDuties = dutiesByDateKey[date] ?? [];
return (
<div
key={date}
className="mb-0"
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
data-date={date}
data-today-block={isToday ? true : undefined}
>
{dayDuties.length > 0 ? (
dayDuties.map((duty) => {
const start = new Date(duty.start_at);
const end = new Date(duty.end_at);
const isCurrent = start <= now && now < end;
return (
<div
key={duty.id}
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<TimelineDateCell
dateLabel={dateLabel}
isToday={isToday}
/>
<span
className="min-w-0"
aria-hidden
/>
<div
className="min-w-0 overflow-hidden"
{...(isCurrent ? { "data-current-duty": true } : {})}
>
<DutyTimelineCard duty={duty} isCurrent={isCurrent} />
</div>
</div>
);
})
) : (
<div
className="grid gap-x-1 items-start mb-2 min-h-0"
style={{
gridTemplateColumns:
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
}}
>
<TimelineDateCell
dateLabel={dateLabel}
isToday={isToday}
/>
<span className="min-w-0" aria-hidden />
<div className="min-w-0" />
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function TimelineDateCell({
dateLabel,
isToday,
}: {
dateLabel: string;
isToday: boolean;
}) {
const { t } = useTranslation();
return (
<span
className={cn(
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
)}
>
{isToday ? (
<>
<span className="duty-timeline-date-label text-today block leading-tight">
{t("duty.today")}
</span>
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
{dateLabel}
</span>
</>
) : (
dateLabel
)}
</span>
);
}

View File

@@ -0,0 +1,188 @@
/**
* Timeline duty card: front = duty info + flip button; back = name + contacts + back button.
* Flip card only when duty has phone or username. Ported from webapp/js/dutyList.js dutyTimelineCardHtml.
*/
"use client";
import { useState, useMemo, useRef } from "react";
import { useTranslation } from "@/i18n/use-translation";
import {
localDateString,
dateKeyToDDMM,
formatHHMM,
} from "@/lib/date-utils";
import { cn } from "@/lib/utils";
import { ContactLinks } from "@/components/contact/ContactLinks";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import type { DutyWithUser } from "@/types";
import { Phone, ArrowLeft } from "lucide-react";
export interface DutyTimelineCardProps {
duty: DutyWithUser;
isCurrent: boolean;
}
function buildTimeStr(duty: DutyWithUser): string {
const startLocal = localDateString(new Date(duty.start_at));
const endLocal = localDateString(new Date(duty.end_at));
const startDDMM = dateKeyToDDMM(startLocal);
const endDDMM = dateKeyToDDMM(endLocal);
const startTime = formatHHMM(duty.start_at);
const endTime = formatHHMM(duty.end_at);
if (startLocal === endLocal) {
return `${startDDMM}, ${startTime} ${endTime}`;
}
return `${startDDMM} ${startTime} ${endDDMM} ${endTime}`;
}
const cardBase =
"grid grid-cols-1 gap-y-0.5 items-baseline rounded-lg bg-surface px-2.5 py-2 border-l-[3px] shadow-sm min-h-0 pr-12 relative";
const borderByType = {
duty: "border-l-duty",
unavailable: "border-l-unavailable",
vacation: "border-l-vacation",
} as const;
/**
* Renders a single duty card. If duty has phone or username, wraps in a flip card
* (front: type, name, time + "Contacts" button; back: name, ContactLinks, "Back" button).
*/
export function DutyTimelineCard({ duty, isCurrent }: DutyTimelineCardProps) {
const { t } = useTranslation();
const [flipped, setFlipped] = useState(false);
const frontBtnRef = useRef<HTMLButtonElement>(null);
const backBtnRef = useRef<HTMLButtonElement>(null);
const hasContacts = Boolean(
(duty.phone && String(duty.phone).trim()) ||
(duty.username && String(duty.username).trim())
);
const typeLabel = isCurrent
? t("duty.now_on_duty")
: t(`event_type.${duty.event_type || "duty"}`);
const timeStr = useMemo(
() => buildTimeStr(duty),
[duty.start_at, duty.end_at]
);
const eventType = (duty.event_type || "duty") as keyof typeof borderByType;
const borderClass = isCurrent ? "border-l-today" : borderByType[eventType] ?? "border-l-duty";
if (!hasContacts) {
return (
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]"
)}
>
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
<span
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
</div>
);
}
return (
<div className="duty-flip-card relative min-h-0 overflow-hidden rounded-lg">
<div
className="duty-flip-inner relative min-h-0 transition-transform duration-300 motion-reduce:duration-[0.01ms]"
style={{
transformStyle: "preserve-3d",
transform: flipped ? "rotateY(180deg)" : "none",
}}
>
{/* Front */}
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
"duty-flip-front"
)}
>
<span className="text-xs text-muted row-start-1">{typeLabel}</span>
<span
className="font-semibold min-w-0 row-start-2 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<span className="text-[0.8rem] text-muted row-start-3">{timeStr}</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={frontBtnRef}
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
aria-label={t("contact.show")}
onClick={() => {
setFlipped(true);
setTimeout(() => backBtnRef.current?.focus(), 310);
}}
>
<Phone className="size-[18px]" aria-hidden />
</Button>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={8}>
{t("contact.show")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{/* Back */}
<div
className={cn(
cardBase,
borderClass,
isCurrent && "bg-[var(--surface-today-tint)]",
"duty-flip-back"
)}
>
<span
className="font-semibold min-w-0 row-start-1 whitespace-nowrap overflow-hidden text-ellipsis"
title={duty.full_name ?? undefined}
>
{duty.full_name}
</span>
<div className="row-start-2 mt-1">
<ContactLinks
phone={duty.phone}
username={duty.username}
layout="inline"
showLabels={false}
/>
</div>
<Button
ref={backBtnRef}
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 size-9 rounded-full bg-surface text-accent hover:bg-accent/20"
aria-label={t("contact.back")}
onClick={() => {
setFlipped(false);
setTimeout(() => frontBtnRef.current?.focus(), 310);
}}
>
<ArrowLeft className="size-[18px]" aria-hidden />
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Duty list and timeline components.
*/
export { DutyList } from "./DutyList";
export { DutyTimelineCard } from "./DutyTimelineCard";
export { DutyItem } from "./DutyItem";
export type { DutyListProps } from "./DutyList";
export type { DutyTimelineCardProps } from "./DutyTimelineCard";
export type { DutyItemProps } from "./DutyItem";

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect } from "react";
import {
init,
mountMiniAppSync,
mountThemeParamsSync,
bindThemeParamsCssVars,
} from "@telegram-apps/sdk-react";
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
/**
* Wraps the app with Telegram Mini App SDK initialization.
* Calls init(acceptCustomStyles), mounts theme params (binds --tg-theme-* CSS vars),
* and mounts the mini app. Does not call ready() here — the app calls
* callMiniAppReadyOnce() from lib/telegram-ready when the first visible screen
* has finished loading, so Telegram keeps its native loading animation until then.
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
* useTelegramTheme() in the app handles ongoing theme changes.
*/
export function TelegramProvider({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
const cleanup = init({ acceptCustomStyles: true });
if (mountThemeParamsSync.isAvailable()) {
mountThemeParamsSync();
}
if (bindThemeParamsCssVars.isAvailable()) {
bindThemeParamsCssVars();
}
fixSurfaceContrast();
void document.documentElement.offsetHeight;
if (mountMiniAppSync.isAvailable()) {
mountMiniAppSync();
}
return cleanup;
}, []);
return <>{children}</>;
}

View File

@@ -0,0 +1,24 @@
/**
* 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

@@ -0,0 +1,46 @@
/**
* 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,26 @@
/**
* Unit tests for ErrorState. Ported from webapp/js/ui.test.js showError.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { ErrorState } from "./ErrorState";
import { resetAppStore } from "@/test/test-utils";
describe("ErrorState", () => {
beforeEach(() => resetAppStore());
it("renders error message", () => {
render(<ErrorState message="Network error" onRetry={undefined} />);
expect(screen.getByText("Network error")).toBeInTheDocument();
});
it("renders Retry button when onRetry provided", () => {
const onRetry = vi.fn();
render(<ErrorState message="Fail" onRetry={onRetry} />);
const retry = screen.getByRole("button", { name: /retry|повторить/i });
expect(retry).toBeInTheDocument();
retry.click();
expect(onRetry).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
/**
* Error state: warning icon, message, and optional Retry button.
* Ported from webapp/js/ui.js showError and states.css .error.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export interface ErrorStateProps {
/** Error message to display. If not provided, uses generic i18n message. */
message?: string | null;
/** Optional retry callback; when provided, a Retry button is shown. */
onRetry?: (() => void) | null;
/** Optional class for the container. */
className?: string;
}
/** Warning triangle icon 24×24 for error state. */
function ErrorIcon({ className }: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn("shrink-0 text-error", className)}
aria-hidden
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
}
/**
* Displays an error message with optional Retry button.
*/
export function ErrorState({ message, onRetry, className }: ErrorStateProps) {
const { t } = useTranslation();
const displayMessage =
message && String(message).trim() ? message : t("error_generic");
return (
<div
className={cn(
"flex flex-col items-center gap-3 rounded-xl bg-surface py-5 px-4 my-3 text-center text-error transition-opacity duration-200",
className
)}
role="alert"
>
<ErrorIcon />
<p className="m-0 text-sm font-medium">{displayMessage}</p>
{typeof onRetry === "function" && (
<Button
type="button"
variant="default"
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"
onClick={onRetry}
>
{t("error.retry")}
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,17 @@
/**
* Unit tests for LoadingState. Ported from webapp/js/ui.test.js (loading).
*/
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { LoadingState } from "./LoadingState";
import { resetAppStore } from "@/test/test-utils";
describe("LoadingState", () => {
beforeEach(() => resetAppStore());
it("renders loading text", () => {
render(<LoadingState />);
expect(screen.getByText(/Loading/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
/**
* Loading state: spinner and translated "Loading…" text.
* Optionally wraps content in a container for calendar placeholder use.
* Ported from webapp CSS states.css .loading and index.html loading element.
*/
"use client";
import { useTranslation } from "@/i18n/use-translation";
import { cn } from "@/lib/utils";
export interface LoadingStateProps {
/** Optional class for the container. */
className?: string;
/** If true, render a compact skeleton-style placeholder (e.g. for calendar area). */
asPlaceholder?: boolean;
}
/**
* Spinner icon matching original .loading__spinner (accent color, reduced-motion safe).
*/
function LoadingSpinner({ className }: { className?: string }) {
return (
<span
className={cn(
"block size-5 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none",
"animate-spin",
className
)}
aria-hidden
/>
);
}
/**
* Full loading view: flex center, spinner + "Loading…" text.
*/
export function LoadingState({ className, asPlaceholder }: LoadingStateProps) {
const { t } = useTranslation();
if (asPlaceholder) {
return (
<div
className={cn(
"flex min-h-[120px] items-center justify-center rounded-lg bg-muted/30",
className
)}
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<LoadingSpinner />
</div>
);
}
return (
<div
className={cn(
"flex items-center justify-center gap-2.5 py-3 text-center text-muted-foreground",
className
)}
role="status"
aria-live="polite"
aria-label={t("loading")}
>
<LoadingSpinner />
<span className="loading__text">{t("loading")}</span>
</div>
);
}

View File

@@ -0,0 +1,7 @@
/**
* State components: loading, error, access denied.
*/
export { LoadingState } from "./LoadingState";
export { ErrorState } from "./ErrorState";
export { AccessDenied } from "./AccessDenied";

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
}

View File

@@ -0,0 +1,163 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
forceMount,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
forceMount={forceMount}
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:duration-300 data-[state=closed]:ease-out data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
onCloseAnimationEnd,
onAnimationEnd,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
/** When provided, content and overlay stay mounted until close animation ends (forceMount). */
onCloseAnimationEnd?: () => void
}) {
const useForceMount = Boolean(onCloseAnimationEnd)
const handleAnimationEnd = React.useCallback(
(e: React.AnimationEvent<HTMLDivElement>) => {
onAnimationEnd?.(e)
if (e.currentTarget.getAttribute("data-state") === "closed") {
onCloseAnimationEnd?.()
}
},
[onAnimationEnd, onCloseAnimationEnd]
)
return (
<SheetPortal>
<SheetOverlay forceMount={useForceMount ? true : undefined} />
<SheetPrimitive.Content
data-slot="sheet-content"
forceMount={useForceMount ? true : undefined}
onAnimationEnd={handleAnimationEnd}
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",
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",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View File

@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-surface fill-surface" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }