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:
176
webapp-next/src/lib/api.test.ts
Normal file
176
webapp-next/src/lib/api.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Unit tests for api: fetchDuties (403 handling, AbortError), fetchCalendarEvents.
|
||||
* Ported from webapp/js/api.test.js. buildFetchOptions is tested indirectly via fetch mock.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
|
||||
|
||||
describe("fetchDuties", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
const validDuty = {
|
||||
id: 1,
|
||||
user_id: 10,
|
||||
start_at: "2025-02-01T09:00:00Z",
|
||||
end_at: "2025-02-01T18:00:00Z",
|
||||
full_name: "Test User",
|
||||
event_type: "duty" as const,
|
||||
phone: null as string | null,
|
||||
username: "@test",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([validDuty]),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns JSON on 200", async () => {
|
||||
const result = await fetchDuties(
|
||||
"2025-02-01",
|
||||
"2025-02-28",
|
||||
"test-init-data",
|
||||
"ru"
|
||||
);
|
||||
expect(result).toEqual([validDuty]);
|
||||
});
|
||||
|
||||
it("sets X-Telegram-Init-Data and Accept-Language headers", async () => {
|
||||
let capturedOpts: RequestInit | null = null;
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((_url: string, opts?: RequestInit) => {
|
||||
capturedOpts = opts ?? null;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
});
|
||||
await fetchDuties("2025-02-01", "2025-02-28", "init-data-string", "en");
|
||||
const headers = capturedOpts?.headers as Record<string, string>;
|
||||
expect(headers["X-Telegram-Init-Data"]).toBe("init-data-string");
|
||||
expect(headers["Accept-Language"]).toBe("en");
|
||||
});
|
||||
|
||||
it("omits X-Telegram-Init-Data when initData empty", async () => {
|
||||
let capturedOpts: RequestInit | null = null;
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((_url: string, opts?: RequestInit) => {
|
||||
capturedOpts = opts ?? null;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve([]),
|
||||
} as Response);
|
||||
});
|
||||
await fetchDuties("2025-02-01", "2025-02-28", "", "ru");
|
||||
const headers = capturedOpts?.headers as Record<string, string>;
|
||||
expect(headers["X-Telegram-Init-Data"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403 with server detail from body", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Custom access denied" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
fetchDuties("2025-02-01", "2025-02-28", "test", "ru")
|
||||
).rejects.toMatchObject({
|
||||
message: "ACCESS_DENIED",
|
||||
serverDetail: "Custom access denied",
|
||||
});
|
||||
await expect(
|
||||
fetchDuties("2025-02-01", "2025-02-28", "test", "ru")
|
||||
).rejects.toThrow(AccessDeniedError);
|
||||
});
|
||||
|
||||
it("rethrows AbortError when request is aborted", async () => {
|
||||
const aborter = new AbortController();
|
||||
const abortError = new DOMException("aborted", "AbortError");
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.reject(abortError));
|
||||
await expect(
|
||||
fetchDuties("2025-02-01", "2025-02-28", "test", "ru", aborter.signal)
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchCalendarEvents", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve([{ date: "2025-02-25", summary: "Holiday" }]),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns JSON array on 200", async () => {
|
||||
const result = await fetchCalendarEvents(
|
||||
"2025-02-01",
|
||||
"2025-02-28",
|
||||
"init-data",
|
||||
"ru"
|
||||
);
|
||||
expect(result).toEqual([{ date: "2025-02-25", summary: "Holiday" }]);
|
||||
});
|
||||
|
||||
it("returns empty array on non-OK response", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as Response);
|
||||
const result = await fetchCalendarEvents(
|
||||
"2025-02-01",
|
||||
"2025-02-28",
|
||||
"init-data",
|
||||
"ru"
|
||||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Access denied" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
fetchCalendarEvents("2025-02-01", "2025-02-28", "init-data", "ru")
|
||||
).rejects.toThrow(AccessDeniedError);
|
||||
await expect(
|
||||
fetchCalendarEvents("2025-02-01", "2025-02-28", "init-data", "ru")
|
||||
).rejects.toMatchObject({
|
||||
message: "ACCESS_DENIED",
|
||||
serverDetail: "Access denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("rethrows AbortError when request is aborted", async () => {
|
||||
const aborter = new AbortController();
|
||||
const abortError = new DOMException("aborted", "AbortError");
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation(() => Promise.reject(abortError));
|
||||
await expect(
|
||||
fetchCalendarEvents(
|
||||
"2025-02-01",
|
||||
"2025-02-28",
|
||||
"init-data",
|
||||
"ru",
|
||||
aborter.signal
|
||||
)
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
209
webapp-next/src/lib/api.ts
Normal file
209
webapp-next/src/lib/api.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* API layer: fetch duties and calendar events with auth header and error handling.
|
||||
* Ported from webapp/js/api.js. Uses fetch with X-Telegram-Init-Data and Accept-Language.
|
||||
* No Next.js fetch caching — client-side SPA with static export.
|
||||
*/
|
||||
|
||||
import { FETCH_TIMEOUT_MS } from "./constants";
|
||||
import { logger } from "./logger";
|
||||
import type { DutyWithUser, CalendarEvent } from "@/types";
|
||||
import { translate } from "@/i18n/messages";
|
||||
|
||||
type ApiLang = "ru" | "en";
|
||||
|
||||
/** Minimal runtime check for a single duty item (required fields). */
|
||||
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return (
|
||||
typeof o.id === "number" &&
|
||||
typeof o.user_id === "number" &&
|
||||
typeof o.start_at === "string" &&
|
||||
typeof o.end_at === "string" &&
|
||||
typeof o.full_name === "string" &&
|
||||
(o.event_type === "duty" || o.event_type === "unavailable" || o.event_type === "vacation") &&
|
||||
(o.phone === null || typeof o.phone === "string") &&
|
||||
(o.username === null || typeof o.username === "string")
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal runtime check for a single calendar event. */
|
||||
function isCalendarEvent(x: unknown): x is CalendarEvent {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
const o = x as Record<string, unknown>;
|
||||
return typeof o.date === "string" && typeof o.summary === "string";
|
||||
}
|
||||
|
||||
function validateDuties(data: unknown, acceptLang: ApiLang): DutyWithUser[] {
|
||||
if (!Array.isArray(data)) {
|
||||
logger.warn("API response is not an array (duties)", typeof data);
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (!isDutyWithUser(data[i])) {
|
||||
logger.warn("API duties item invalid at index", i, data[i]);
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
}
|
||||
return data as DutyWithUser[];
|
||||
}
|
||||
|
||||
function validateCalendarEvents(data: unknown): CalendarEvent[] {
|
||||
if (!Array.isArray(data)) {
|
||||
logger.warn("API response is not an array (calendar-events)", typeof data);
|
||||
return [];
|
||||
}
|
||||
const out: CalendarEvent[] = [];
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (isCalendarEvent(data[i])) {
|
||||
out.push(data[i]);
|
||||
} else {
|
||||
logger.warn("API calendar-events item invalid at index", i, data[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const API_ACCESS_DENIED = "ACCESS_DENIED";
|
||||
|
||||
/** Error thrown on 403 with server detail attached. */
|
||||
export class AccessDeniedError extends Error {
|
||||
constructor(
|
||||
message: string = API_ACCESS_DENIED,
|
||||
public serverDetail?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AccessDeniedError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build fetch options with init data header, Accept-Language and timeout abort.
|
||||
* Optional external signal aborts this request when triggered.
|
||||
*/
|
||||
function buildFetchOptions(
|
||||
initData: string,
|
||||
acceptLang: ApiLang,
|
||||
externalSignal?: AbortSignal | null
|
||||
): { headers: HeadersInit; signal: AbortSignal; cleanup: () => void } {
|
||||
const headers: Record<string, string> = {
|
||||
"Accept-Language": acceptLang || "en",
|
||||
};
|
||||
if (initData) {
|
||||
headers["X-Telegram-Init-Data"] = initData;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
const onAbort = (): void => controller.abort();
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timeoutId);
|
||||
if (externalSignal) {
|
||||
externalSignal.removeEventListener("abort", onAbort);
|
||||
}
|
||||
};
|
||||
if (externalSignal) {
|
||||
if (externalSignal.aborted) {
|
||||
cleanup();
|
||||
controller.abort();
|
||||
} else {
|
||||
externalSignal.addEventListener("abort", onAbort);
|
||||
}
|
||||
}
|
||||
return { headers, signal: controller.signal, cleanup };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch duties for date range. Throws AccessDeniedError on 403.
|
||||
* Rethrows AbortError when the request is cancelled (e.g. stale load).
|
||||
*/
|
||||
export async function fetchDuties(
|
||||
from: string,
|
||||
to: string,
|
||||
initData: string,
|
||||
acceptLang: ApiLang,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<DutyWithUser[]> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/duties?${new URLSearchParams({ from, to }).toString()}`;
|
||||
const opts = buildFetchOptions(initData, acceptLang, signal);
|
||||
try {
|
||||
logger.debug("API request", "/api/duties", { from, to });
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
logger.warn("Access denied", from, to);
|
||||
let detail = translate(acceptLang, "access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
const data = await res.json();
|
||||
return validateDuties(data, acceptLang);
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
logger.error("API request failed", "/api/duties", e);
|
||||
throw e;
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch calendar events for range. Throws AccessDeniedError on 403.
|
||||
* Returns [] on other non-200. Rethrows AbortError when the request is cancelled.
|
||||
*/
|
||||
export async function fetchCalendarEvents(
|
||||
from: string,
|
||||
to: string,
|
||||
initData: string,
|
||||
acceptLang: ApiLang,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<CalendarEvent[]> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/calendar-events?${new URLSearchParams({ from, to }).toString()}`;
|
||||
const opts = buildFetchOptions(initData, acceptLang, signal);
|
||||
try {
|
||||
logger.debug("API request", "/api/calendar-events", { from, to });
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
logger.warn("Access denied", from, to, "calendar-events");
|
||||
let detail = translate(acceptLang, "access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return validateCalendarEvents(data);
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
logger.error("API request failed", "/api/calendar-events", e);
|
||||
return [];
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
114
webapp-next/src/lib/calendar-data.test.ts
Normal file
114
webapp-next/src/lib/calendar-data.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Unit tests for calendar-data: dutiesByDate (including edge case end_at < start_at),
|
||||
* calendarEventsByDate. Ported from webapp/js/calendar.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { dutiesByDate, calendarEventsByDate } from "./calendar-data";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import type { CalendarEvent } from "@/types";
|
||||
|
||||
describe("dutiesByDate", () => {
|
||||
it("groups duty by single local day", () => {
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name: "Alice",
|
||||
start_at: "2025-02-25T09:00:00Z",
|
||||
end_at: "2025-02-25T18:00:00Z",
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
const byDate = dutiesByDate(duties);
|
||||
expect(byDate["2025-02-25"]).toHaveLength(1);
|
||||
expect(byDate["2025-02-25"][0].full_name).toBe("Alice");
|
||||
});
|
||||
|
||||
it("spans duty across multiple days", () => {
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name: "Bob",
|
||||
start_at: "2025-02-25T00:00:00Z",
|
||||
end_at: "2025-02-27T23:59:59Z",
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
const byDate = dutiesByDate(duties);
|
||||
const keys = Object.keys(byDate).sort();
|
||||
expect(keys.length).toBeGreaterThanOrEqual(2);
|
||||
keys.forEach((k) => expect(byDate[k]).toHaveLength(1));
|
||||
expect(byDate[keys[0]][0].full_name).toBe("Bob");
|
||||
});
|
||||
|
||||
it("skips duty when end_at < start_at (no infinite loop)", () => {
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name: "Bad",
|
||||
start_at: "2025-02-28T12:00:00Z",
|
||||
end_at: "2025-02-25T08:00:00Z",
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
const byDate = dutiesByDate(duties);
|
||||
expect(Object.keys(byDate)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not iterate more than MAX_DAYS_PER_DUTY", () => {
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name: "Long",
|
||||
start_at: "2025-01-01T00:00:00Z",
|
||||
end_at: "2026-06-01T00:00:00Z",
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
const byDate = dutiesByDate(duties);
|
||||
const keys = Object.keys(byDate).sort();
|
||||
expect(keys.length).toBeLessThanOrEqual(367);
|
||||
});
|
||||
|
||||
it("handles empty duties", () => {
|
||||
expect(dutiesByDate([])).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendarEventsByDate", () => {
|
||||
it("maps events to local date key by UTC date", () => {
|
||||
const events: CalendarEvent[] = [
|
||||
{ date: "2025-02-25", summary: "Holiday" },
|
||||
{ date: "2025-02-25", summary: "Meeting" },
|
||||
{ date: "2025-02-26", summary: "Other" },
|
||||
];
|
||||
const byDate = calendarEventsByDate(events);
|
||||
expect(byDate["2025-02-25"]).toEqual(["Holiday", "Meeting"]);
|
||||
expect(byDate["2025-02-26"]).toEqual(["Other"]);
|
||||
});
|
||||
|
||||
it("skips events without summary", () => {
|
||||
const events = [
|
||||
{ date: "2025-02-25", summary: null as unknown as string },
|
||||
];
|
||||
const byDate = calendarEventsByDate(events);
|
||||
expect(byDate["2025-02-25"] ?? []).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles null or undefined events", () => {
|
||||
expect(calendarEventsByDate(null)).toEqual({});
|
||||
expect(calendarEventsByDate(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
50
webapp-next/src/lib/calendar-data.ts
Normal file
50
webapp-next/src/lib/calendar-data.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Pure functions: group duties and calendar events by local date (YYYY-MM-DD).
|
||||
* Ported from webapp/js/calendar.js (dutiesByDate, calendarEventsByDate).
|
||||
*/
|
||||
|
||||
import type { CalendarEvent, DutyWithUser } from "@/types";
|
||||
import { localDateString } from "./date-utils";
|
||||
|
||||
/** Max days to iterate per duty; prevents infinite loop on corrupted API data (end_at < start_at). */
|
||||
const MAX_DAYS_PER_DUTY = 366;
|
||||
|
||||
/**
|
||||
* Build { localDateKey -> array of summary strings }. API date is UTC YYYY-MM-DD; map to local.
|
||||
*/
|
||||
export function calendarEventsByDate(
|
||||
events: CalendarEvent[] | null | undefined
|
||||
): Record<string, string[]> {
|
||||
const byDate: Record<string, string[]> = {};
|
||||
(events ?? []).forEach((e) => {
|
||||
const utcMidnight = new Date(e.date + "T00:00:00Z");
|
||||
const key = localDateString(utcMidnight);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
if (e.summary) byDate[key].push(e.summary);
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group duties by local date (start_at/end_at are UTC).
|
||||
*/
|
||||
export function dutiesByDate(duties: DutyWithUser[]): Record<string, DutyWithUser[]> {
|
||||
const byDate: Record<string, DutyWithUser[]> = {};
|
||||
duties.forEach((d) => {
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
if (end < start) return;
|
||||
const endLocal = localDateString(end);
|
||||
let cursor = new Date(start);
|
||||
let iterations = 0;
|
||||
while (iterations <= MAX_DAYS_PER_DUTY) {
|
||||
const key = localDateString(cursor);
|
||||
if (!byDate[key]) byDate[key] = [];
|
||||
byDate[key].push(d);
|
||||
if (key === endLocal) break;
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
iterations++;
|
||||
}
|
||||
});
|
||||
return byDate;
|
||||
}
|
||||
9
webapp-next/src/lib/constants.ts
Normal file
9
webapp-next/src/lib/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Application constants for API and retry behaviour.
|
||||
*/
|
||||
|
||||
export const FETCH_TIMEOUT_MS = 15000;
|
||||
export const RETRY_DELAY_MS = 800;
|
||||
export const RETRY_AFTER_ACCESS_DENIED_MS = 1200;
|
||||
export const RETRY_AFTER_ERROR_MS = 800;
|
||||
export const MAX_GENERAL_RETRIES = 2;
|
||||
87
webapp-next/src/lib/current-duty.test.ts
Normal file
87
webapp-next/src/lib/current-duty.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Unit tests for current-duty: getRemainingTime, findCurrentDuty.
|
||||
* Ported from webapp/js/currentDuty.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { getRemainingTime, findCurrentDuty } from "./current-duty";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
describe("getRemainingTime", () => {
|
||||
it("returns hours and minutes until end from now", () => {
|
||||
const endAt = "2025-03-02T17:30:00.000Z";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
|
||||
const { hours, minutes } = getRemainingTime(endAt);
|
||||
vi.useRealTimers();
|
||||
expect(hours).toBe(5);
|
||||
expect(minutes).toBe(30);
|
||||
});
|
||||
|
||||
it("returns 0 when end is in the past", () => {
|
||||
const endAt = "2025-03-02T09:00:00.000Z";
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2025-03-02T12:00:00.000Z"));
|
||||
const { hours, minutes } = getRemainingTime(endAt);
|
||||
vi.useRealTimers();
|
||||
expect(hours).toBe(0);
|
||||
expect(minutes).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 hours and 0 minutes when endAt is invalid", () => {
|
||||
const { hours, minutes } = getRemainingTime("not-a-date");
|
||||
expect(hours).toBe(0);
|
||||
expect(minutes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findCurrentDuty", () => {
|
||||
it("returns duty when now is between start_at and end_at", () => {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setHours(start.getHours() - 1, 0, 0, 0);
|
||||
const end = new Date(now);
|
||||
end.setHours(end.getHours() + 1, 0, 0, 0);
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
event_type: "duty",
|
||||
full_name: "Иванов",
|
||||
start_at: start.toISOString(),
|
||||
end_at: end.toISOString(),
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
const duty = findCurrentDuty(duties);
|
||||
expect(duty).not.toBeNull();
|
||||
expect(duty?.full_name).toBe("Иванов");
|
||||
});
|
||||
|
||||
it("returns null when no duty overlaps current time", () => {
|
||||
const duties: DutyWithUser[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
event_type: "duty",
|
||||
full_name: "Past",
|
||||
start_at: "2020-01-01T09:00:00Z",
|
||||
end_at: "2020-01-01T17:00:00Z",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: 2,
|
||||
event_type: "duty",
|
||||
full_name: "Future",
|
||||
start_at: "2030-01-01T09:00:00Z",
|
||||
end_at: "2030-01-01T17:00:00Z",
|
||||
phone: null,
|
||||
username: null,
|
||||
},
|
||||
];
|
||||
expect(findCurrentDuty(duties)).toBeNull();
|
||||
});
|
||||
});
|
||||
41
webapp-next/src/lib/current-duty.ts
Normal file
41
webapp-next/src/lib/current-duty.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Current duty helpers: remaining time and active duty lookup.
|
||||
* Ported from webapp/js/currentDuty.js (getRemainingTime, findCurrentDuty).
|
||||
*/
|
||||
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
/**
|
||||
* Compute remaining time until end of shift. Call only when now < end (active duty).
|
||||
*
|
||||
* @param endAt - ISO end time of the shift
|
||||
* @returns Object with hours and minutes remaining
|
||||
*/
|
||||
export function getRemainingTime(endAt: string | Date): { hours: number; minutes: number } {
|
||||
const end = new Date(endAt).getTime();
|
||||
if (isNaN(end)) return { hours: 0, minutes: 0 };
|
||||
const now = Date.now();
|
||||
const ms = Math.max(0, end - now);
|
||||
const hours = Math.floor(ms / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the duty that is currently active (start <= now < end). Prefer event_type === "duty".
|
||||
*
|
||||
* @param duties - List of duties with start_at, end_at, event_type
|
||||
* @returns The active duty or null
|
||||
*/
|
||||
export function findCurrentDuty(duties: DutyWithUser[] | null | undefined): DutyWithUser | null {
|
||||
const list = duties ?? [];
|
||||
const dutyType = list.filter((d) => d.event_type === "duty");
|
||||
const candidates = dutyType.length > 0 ? dutyType : list;
|
||||
const now = Date.now();
|
||||
for (const d of candidates) {
|
||||
const start = new Date(d.start_at).getTime();
|
||||
const end = new Date(d.end_at).getTime();
|
||||
if (start <= now && now < end) return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
240
webapp-next/src/lib/date-utils.test.ts
Normal file
240
webapp-next/src/lib/date-utils.test.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Unit tests for date-utils. Ported from webapp/js/dateUtils.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
localDateString,
|
||||
dutyOverlapsLocalDay,
|
||||
dutyOverlapsLocalRange,
|
||||
getMonday,
|
||||
formatHHMM,
|
||||
firstDayOfMonth,
|
||||
lastDayOfMonth,
|
||||
formatDateKey,
|
||||
dateKeyToDDMM,
|
||||
} from "./date-utils";
|
||||
|
||||
describe("localDateString", () => {
|
||||
it("formats date as YYYY-MM-DD", () => {
|
||||
const d = new Date(2025, 0, 15);
|
||||
expect(localDateString(d)).toBe("2025-01-15");
|
||||
});
|
||||
|
||||
it("pads month and day with zero", () => {
|
||||
expect(localDateString(new Date(2025, 0, 5))).toBe("2025-01-05");
|
||||
expect(localDateString(new Date(2025, 8, 9))).toBe("2025-09-09");
|
||||
});
|
||||
|
||||
it("handles December and year boundary", () => {
|
||||
expect(localDateString(new Date(2024, 11, 31))).toBe("2024-12-31");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dutyOverlapsLocalDay", () => {
|
||||
it("returns true when duty spans the whole day", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-25T00:00:00Z",
|
||||
end_at: "2025-02-25T23:59:59Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when duty overlaps part of the day", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-25T09:00:00Z",
|
||||
end_at: "2025-02-25T14:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when duty continues from previous day", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-24T22:00:00Z",
|
||||
end_at: "2025-02-25T06:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when duty ends before the day", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-24T09:00:00Z",
|
||||
end_at: "2025-02-24T18:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when duty starts after the day", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-26T09:00:00Z",
|
||||
end_at: "2025-02-26T18:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalDay(d, "2025-02-25")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dutyOverlapsLocalRange", () => {
|
||||
it("returns true when duty overlaps the range", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-24T12:00:00Z",
|
||||
end_at: "2025-02-26T12:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when duty is entirely inside the range", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-26T09:00:00Z",
|
||||
end_at: "2025-02-26T18:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when duty ends before range start", () => {
|
||||
const d = {
|
||||
start_at: "2025-02-20T09:00:00Z",
|
||||
end_at: "2025-02-22T18:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when duty starts after range end", () => {
|
||||
const d = {
|
||||
start_at: "2025-03-01T09:00:00Z",
|
||||
end_at: "2025-03-01T18:00:00Z",
|
||||
};
|
||||
expect(dutyOverlapsLocalRange(d, "2025-02-25", "2025-02-28")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonday", () => {
|
||||
it("returns same day when date is Monday", () => {
|
||||
const monday = new Date(2025, 0, 6); // 6 Jan 2025 is Monday
|
||||
const result = getMonday(monday);
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
expect(result.getMonth()).toBe(0);
|
||||
expect(result.getDate()).toBe(6);
|
||||
expect(result.getDay()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns previous Monday for Wednesday", () => {
|
||||
const wed = new Date(2025, 0, 8);
|
||||
const result = getMonday(wed);
|
||||
expect(result.getDay()).toBe(1);
|
||||
expect(result.getDate()).toBe(6);
|
||||
});
|
||||
|
||||
it("returns Monday of same week for Sunday", () => {
|
||||
const sun = new Date(2025, 0, 12);
|
||||
const result = getMonday(sun);
|
||||
expect(result.getDay()).toBe(1);
|
||||
expect(result.getDate()).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatHHMM", () => {
|
||||
it("formats ISO string as HH:MM in local time", () => {
|
||||
const s = "2025-02-25T14:30:00Z";
|
||||
const result = formatHHMM(s);
|
||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||
const d = new Date(s);
|
||||
const expected =
|
||||
(d.getHours() < 10 ? "0" : "") +
|
||||
d.getHours() +
|
||||
":" +
|
||||
(d.getMinutes() < 10 ? "0" : "") +
|
||||
d.getMinutes();
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatHHMM(null as unknown as string)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatHHMM(undefined as unknown as string)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(formatHHMM("")).toBe("");
|
||||
});
|
||||
|
||||
it("pads hours and minutes with zero", () => {
|
||||
const s = "2025-02-25T09:05:00Z";
|
||||
const result = formatHHMM(s);
|
||||
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("firstDayOfMonth", () => {
|
||||
it("returns first day of month", () => {
|
||||
const d = new Date(2025, 5, 15);
|
||||
const result = firstDayOfMonth(d);
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
expect(result.getMonth()).toBe(5);
|
||||
expect(result.getDate()).toBe(1);
|
||||
});
|
||||
|
||||
it("handles January", () => {
|
||||
const d = new Date(2025, 0, 31);
|
||||
const result = firstDayOfMonth(d);
|
||||
expect(result.getDate()).toBe(1);
|
||||
expect(result.getMonth()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastDayOfMonth", () => {
|
||||
it("returns last day of month", () => {
|
||||
const d = new Date(2025, 0, 15);
|
||||
const result = lastDayOfMonth(d);
|
||||
expect(result.getFullYear()).toBe(2025);
|
||||
expect(result.getMonth()).toBe(0);
|
||||
expect(result.getDate()).toBe(31);
|
||||
});
|
||||
|
||||
it("returns 28 for non-leap February", () => {
|
||||
const d = new Date(2023, 1, 1);
|
||||
const result = lastDayOfMonth(d);
|
||||
expect(result.getDate()).toBe(28);
|
||||
expect(result.getMonth()).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 29 for leap February", () => {
|
||||
const d = new Date(2024, 1, 1);
|
||||
const result = lastDayOfMonth(d);
|
||||
expect(result.getDate()).toBe(29);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDateKey", () => {
|
||||
it("formats ISO date string as DD.MM (local time)", () => {
|
||||
const result = formatDateKey("2025-02-25T00:00:00Z");
|
||||
expect(result).toMatch(/^\d{2}\.\d{2}$/);
|
||||
const [day, month] = result.split(".");
|
||||
expect(Number(day)).toBeGreaterThanOrEqual(1);
|
||||
expect(Number(day)).toBeLessThanOrEqual(31);
|
||||
expect(Number(month)).toBeGreaterThanOrEqual(1);
|
||||
expect(Number(month)).toBeLessThanOrEqual(12);
|
||||
});
|
||||
|
||||
it("returns DD.MM format with zero-padding", () => {
|
||||
const result = formatDateKey("2025-01-05T12:00:00Z");
|
||||
expect(result).toMatch(/^\d{2}\.\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateKeyToDDMM", () => {
|
||||
it("converts YYYY-MM-DD to DD.MM", () => {
|
||||
expect(dateKeyToDDMM("2025-02-25")).toBe("25.02");
|
||||
});
|
||||
|
||||
it("handles single-digit day and month", () => {
|
||||
expect(dateKeyToDDMM("2025-01-09")).toBe("09.01");
|
||||
});
|
||||
|
||||
it("returns key unchanged when format is not YYYY-MM-DD", () => {
|
||||
expect(dateKeyToDDMM("short")).toBe("short");
|
||||
expect(dateKeyToDDMM("2025/02/25")).toBe("2025/02/25");
|
||||
expect(dateKeyToDDMM("20250225")).toBe("20250225");
|
||||
});
|
||||
});
|
||||
94
webapp-next/src/lib/date-utils.ts
Normal file
94
webapp-next/src/lib/date-utils.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Date/time helpers for calendar and duty display.
|
||||
* Ported from webapp/js/dateUtils.js.
|
||||
*/
|
||||
|
||||
/**
|
||||
* YYYY-MM-DD in local time (for calendar keys, "today", request range).
|
||||
*/
|
||||
export function localDateString(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** Duty-like object with start_at and end_at (UTC ISO strings). */
|
||||
export interface DutyLike {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if duty (start_at/end_at UTC) overlaps the local day YYYY-MM-DD.
|
||||
*/
|
||||
export function dutyOverlapsLocalDay(d: DutyLike, dateKey: string): boolean {
|
||||
const [y, m, day] = dateKey.split("-").map(Number);
|
||||
const dayStart = new Date(y, m - 1, day, 0, 0, 0, 0);
|
||||
const dayEnd = new Date(y, m - 1, day, 23, 59, 59, 999);
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
return end > dayStart && start < dayEnd;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if duty overlaps any local day in the range [firstKey, lastKey] (inclusive).
|
||||
*/
|
||||
export function dutyOverlapsLocalRange(
|
||||
d: DutyLike,
|
||||
firstKey: string,
|
||||
lastKey: string
|
||||
): boolean {
|
||||
const [y1, m1, day1] = firstKey.split("-").map(Number);
|
||||
const [y2, m2, day2] = lastKey.split("-").map(Number);
|
||||
const rangeStart = new Date(y1, m1 - 1, day1, 0, 0, 0, 0);
|
||||
const rangeEnd = new Date(y2, m2 - 1, day2, 23, 59, 59, 999);
|
||||
const start = new Date(d.start_at);
|
||||
const end = new Date(d.end_at);
|
||||
return end > rangeStart && start < rangeEnd;
|
||||
}
|
||||
|
||||
export function firstDayOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
}
|
||||
|
||||
export function lastDayOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
}
|
||||
|
||||
/** Returns Monday of the week for the given date. */
|
||||
export function getMonday(d: Date): Date {
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
return new Date(d.getFullYear(), d.getMonth(), diff);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format UTC date from ISO string as DD.MM for display.
|
||||
*/
|
||||
export function formatDateKey(isoDateStr: string): string {
|
||||
const d = new Date(isoDateStr);
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
const month = String(d.getMonth() + 1).padStart(2, "0");
|
||||
return `${day}.${month}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format YYYY-MM-DD as DD.MM for list header.
|
||||
* Returns key unchanged if it does not match YYYY-MM-DD format.
|
||||
*/
|
||||
export function dateKeyToDDMM(key: string): string {
|
||||
if (key.length < 10 || key[4] !== "-" || key[7] !== "-") return key;
|
||||
return key.slice(8, 10) + "." + key.slice(5, 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO string as HH:MM (local).
|
||||
*/
|
||||
export function formatHHMM(isoStr: string): string {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
const h = d.getHours();
|
||||
const m = d.getMinutes();
|
||||
return (h < 10 ? "0" : "") + h + ":" + (m < 10 ? "0" : "") + m;
|
||||
}
|
||||
114
webapp-next/src/lib/duty-marker-rows.test.ts
Normal file
114
webapp-next/src/lib/duty-marker-rows.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Unit tests for getDutyMarkerRows (time prefix and order).
|
||||
* Ported from webapp/js/hints.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getDutyMarkerRows } from "./duty-marker-rows";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
const FROM = "from";
|
||||
const TO = "until";
|
||||
const SEP = "\u00a0";
|
||||
|
||||
function duty(
|
||||
full_name: string,
|
||||
start_at: string,
|
||||
end_at: string
|
||||
): DutyWithUser {
|
||||
return {
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
full_name,
|
||||
start_at,
|
||||
end_at,
|
||||
event_type: "duty",
|
||||
phone: null,
|
||||
username: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("getDutyMarkerRows", () => {
|
||||
it("preserves input order (caller must sort by start_at before passing)", () => {
|
||||
const hintDay = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Иванов", "2025-02-25T14:00:00", "2025-02-25T18:00:00"),
|
||||
duty("Петров", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
|
||||
];
|
||||
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].fullName).toBe("Иванов");
|
||||
expect(rows[1].fullName).toBe("Петров");
|
||||
});
|
||||
|
||||
it("first of multiple with startSameDay shows full range (from HH:MM to HH:MM)", () => {
|
||||
const hintDay = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Иванов", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
|
||||
duty("Петров", "2025-02-25T14:00:00", "2025-02-25T18:00:00"),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].fullName).toBe("Иванов");
|
||||
expect(rows[0].timePrefix).toContain("09:00");
|
||||
expect(rows[0].timePrefix).toContain("14:00");
|
||||
expect(rows[0].timePrefix).toContain(FROM);
|
||||
expect(rows[0].timePrefix).toContain(TO);
|
||||
});
|
||||
|
||||
it("first of multiple continuation from previous day shows only end time", () => {
|
||||
const hintDay = "2025-02-25";
|
||||
const duties = [
|
||||
duty("Иванов", "2025-02-24T22:00:00", "2025-02-25T06:00:00"),
|
||||
duty("Петров", "2025-02-25T09:00:00", "2025-02-25T14:00:00"),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].fullName).toBe("Иванов");
|
||||
expect(rows[0].timePrefix).not.toContain(FROM);
|
||||
expect(rows[0].timePrefix).toContain(TO);
|
||||
expect(rows[0].timePrefix).toContain("06:00");
|
||||
});
|
||||
|
||||
it("second duty continuation from previous day shows only end time (to HH:MM)", () => {
|
||||
const hintDay = "2025-02-23";
|
||||
const duties = [
|
||||
duty("A", "2025-02-23T00:00:00", "2025-02-23T09:00:00"),
|
||||
duty("B", "2025-02-22T09:00:00", "2025-02-23T09:00:00"),
|
||||
];
|
||||
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0].fullName).toBe("A");
|
||||
expect(rows[0].timePrefix).toContain(FROM);
|
||||
expect(rows[0].timePrefix).toContain("00:00");
|
||||
expect(rows[0].timePrefix).toContain(TO);
|
||||
expect(rows[0].timePrefix).toContain("09:00");
|
||||
expect(rows[1].fullName).toBe("B");
|
||||
expect(rows[1].timePrefix).not.toContain(FROM);
|
||||
expect(rows[1].timePrefix).toContain(TO);
|
||||
expect(rows[1].timePrefix).toContain("09:00");
|
||||
});
|
||||
|
||||
it("multiple duties in one day — correct order when input is pre-sorted", () => {
|
||||
const hintDay = "2025-02-25";
|
||||
const duties = [
|
||||
duty("A", "2025-02-25T09:00:00", "2025-02-25T12:00:00"),
|
||||
duty("B", "2025-02-25T12:00:00", "2025-02-25T15:00:00"),
|
||||
duty("C", "2025-02-25T15:00:00", "2025-02-25T18:00:00"),
|
||||
].sort(
|
||||
(a, b) =>
|
||||
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
||||
);
|
||||
const rows = getDutyMarkerRows(duties, hintDay, SEP, FROM, TO);
|
||||
expect(rows.map((r) => r.fullName)).toEqual(["A", "B", "C"]);
|
||||
expect(rows[0].timePrefix).toContain("09:00");
|
||||
expect(rows[1].timePrefix).toContain("12:00");
|
||||
expect(rows[2].timePrefix).toContain("15:00");
|
||||
});
|
||||
});
|
||||
97
webapp-next/src/lib/duty-marker-rows.ts
Normal file
97
webapp-next/src/lib/duty-marker-rows.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Build time-prefix rows for duty items in day detail (single source of time rules).
|
||||
* Ported from webapp/js/hints.js getDutyMarkerRows and buildDutyItemTimePrefix.
|
||||
*/
|
||||
|
||||
import { localDateString, formatHHMM } from "./date-utils";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
|
||||
export interface DutyMarkerRow {
|
||||
id: number;
|
||||
timePrefix: string;
|
||||
fullName: string;
|
||||
phone?: string | null;
|
||||
username?: string | null;
|
||||
}
|
||||
|
||||
function getItemFullName(item: DutyWithUser): string {
|
||||
return item.full_name ?? "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build time prefix for one duty item (e.g. "from 09:00 until 18:00").
|
||||
*/
|
||||
function buildDutyItemTimePrefix(
|
||||
item: DutyWithUser,
|
||||
idx: number,
|
||||
total: number,
|
||||
hintDay: string,
|
||||
sep: string,
|
||||
fromLabel: string,
|
||||
toLabel: string
|
||||
): string {
|
||||
const startAt = item.start_at;
|
||||
const endAt = item.end_at;
|
||||
const endHHMM = endAt ? formatHHMM(endAt) : "";
|
||||
const startHHMM = startAt ? formatHHMM(startAt) : "";
|
||||
const startSameDay =
|
||||
hintDay && startAt && localDateString(new Date(startAt)) === hintDay;
|
||||
const endSameDay =
|
||||
hintDay && endAt && localDateString(new Date(endAt)) === hintDay;
|
||||
let timePrefix = "";
|
||||
if (idx === 0) {
|
||||
if (total === 1 && startSameDay && startHHMM) {
|
||||
timePrefix = fromLabel + sep + startHHMM;
|
||||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||
timePrefix += " " + toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (startSameDay && startHHMM) {
|
||||
timePrefix = fromLabel + sep + startHHMM;
|
||||
if (endSameDay && endHHMM && endHHMM !== startHHMM) {
|
||||
timePrefix += " " + toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (endHHMM) {
|
||||
timePrefix = toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (idx > 0) {
|
||||
if (startSameDay && startHHMM) {
|
||||
timePrefix = fromLabel + sep + startHHMM;
|
||||
if (endHHMM && endSameDay && endHHMM !== startHHMM) {
|
||||
timePrefix += " " + toLabel + sep + endHHMM;
|
||||
}
|
||||
} else if (endHHMM) {
|
||||
timePrefix = toLabel + sep + endHHMM;
|
||||
}
|
||||
}
|
||||
return timePrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get array of { timePrefix, fullName, phone?, username? } for duty items.
|
||||
*/
|
||||
export function getDutyMarkerRows(
|
||||
dutyItems: DutyWithUser[],
|
||||
hintDay: string,
|
||||
timeSep: string,
|
||||
fromLabel: string,
|
||||
toLabel: string
|
||||
): DutyMarkerRow[] {
|
||||
return dutyItems.map((item, idx) => {
|
||||
const timePrefix = buildDutyItemTimePrefix(
|
||||
item,
|
||||
idx,
|
||||
dutyItems.length,
|
||||
hintDay,
|
||||
timeSep,
|
||||
fromLabel,
|
||||
toLabel
|
||||
);
|
||||
return {
|
||||
id: item.id,
|
||||
timePrefix,
|
||||
fullName: getItemFullName(item),
|
||||
phone: item.phone,
|
||||
username: item.username,
|
||||
};
|
||||
});
|
||||
}
|
||||
53
webapp-next/src/lib/launch-params.test.ts
Normal file
53
webapp-next/src/lib/launch-params.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unit tests for launch-params: getStartParamFromUrl (deep link start_param).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { getStartParamFromUrl } from "./launch-params";
|
||||
|
||||
describe("getStartParamFromUrl", () => {
|
||||
const origLocation = window.location;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: origLocation,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns start_param from search", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, search: "?tgWebAppStartParam=duty", hash: "" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getStartParamFromUrl()).toBe("duty");
|
||||
});
|
||||
|
||||
it("returns start_param from hash when not in search", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, search: "", hash: "#tgWebAppStartParam=duty" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getStartParamFromUrl()).toBe("duty");
|
||||
});
|
||||
|
||||
it("prefers search over hash", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
...origLocation,
|
||||
search: "?tgWebAppStartParam=calendar",
|
||||
hash: "#tgWebAppStartParam=duty",
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
expect(getStartParamFromUrl()).toBe("calendar");
|
||||
});
|
||||
|
||||
it("returns undefined when param is absent", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
value: { ...origLocation, search: "", hash: "" },
|
||||
writable: true,
|
||||
});
|
||||
expect(getStartParamFromUrl()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
20
webapp-next/src/lib/launch-params.ts
Normal file
20
webapp-next/src/lib/launch-params.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Launch params from URL (query or hash). Used for deep links and initial view.
|
||||
* Telegram may pass tgWebAppStartParam in search or hash; both are checked.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse start_param from URL (query or hash). Use when SDK is not yet available
|
||||
* (e.g. store init) or as fallback when SDK is delayed.
|
||||
*/
|
||||
export function getStartParamFromUrl(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const fromSearch = new URLSearchParams(window.location.search).get(
|
||||
"tgWebAppStartParam"
|
||||
);
|
||||
if (fromSearch) return fromSearch;
|
||||
const fromHash = new URLSearchParams(window.location.hash.slice(1)).get(
|
||||
"tgWebAppStartParam"
|
||||
);
|
||||
return fromHash ?? undefined;
|
||||
}
|
||||
93
webapp-next/src/lib/logger.test.ts
Normal file
93
webapp-next/src/lib/logger.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Unit tests for logger: level filtering and console delegation.
|
||||
* Ported from webapp/js/logger.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { logger } from "./logger";
|
||||
|
||||
describe("logger", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "debug").mockImplementation(() => {});
|
||||
vi.spyOn(console, "info").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (globalThis.window) {
|
||||
delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
|
||||
}
|
||||
});
|
||||
|
||||
function setLevel(level: string) {
|
||||
if (!globalThis.window) (globalThis as unknown as { window: object }).window = {};
|
||||
(globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL = level;
|
||||
}
|
||||
|
||||
it("at level info does not call console.debug", () => {
|
||||
setLevel("info");
|
||||
logger.debug("test");
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("at level info calls console.info for logger.info", () => {
|
||||
setLevel("info");
|
||||
logger.info("hello");
|
||||
expect(console.info).toHaveBeenCalledWith("[DutyTeller][info]", "hello");
|
||||
});
|
||||
|
||||
it("at level info calls console.warn and console.error", () => {
|
||||
setLevel("info");
|
||||
logger.warn("w");
|
||||
logger.error("e");
|
||||
expect(console.warn).toHaveBeenCalledWith("[DutyTeller][warn]", "w");
|
||||
expect(console.error).toHaveBeenCalledWith("[DutyTeller][error]", "e");
|
||||
});
|
||||
|
||||
it("at level debug calls console.debug", () => {
|
||||
setLevel("debug");
|
||||
logger.debug("dbg");
|
||||
expect(console.debug).toHaveBeenCalledWith("[DutyTeller][debug]", "dbg");
|
||||
});
|
||||
|
||||
it("at level error does not call console.debug or console.info", () => {
|
||||
setLevel("error");
|
||||
logger.debug("d");
|
||||
logger.info("i");
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
expect(console.info).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("at level error calls console.error", () => {
|
||||
setLevel("error");
|
||||
logger.error("err");
|
||||
expect(console.error).toHaveBeenCalledWith("[DutyTeller][error]", "err");
|
||||
});
|
||||
|
||||
it("passes extra args to console", () => {
|
||||
setLevel("info");
|
||||
logger.info("msg", { foo: 1 }, "bar");
|
||||
expect(console.info).toHaveBeenCalledWith(
|
||||
"[DutyTeller][info]",
|
||||
"msg",
|
||||
{ foo: 1 },
|
||||
"bar"
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to info when __DT_LOG_LEVEL is missing", () => {
|
||||
if (globalThis.window) delete (globalThis.window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL;
|
||||
logger.debug("no");
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
logger.info("yes");
|
||||
expect(console.info).toHaveBeenCalledWith("[DutyTeller][info]", "yes");
|
||||
});
|
||||
|
||||
it("defaults to info when __DT_LOG_LEVEL is invalid", () => {
|
||||
setLevel("invalid");
|
||||
logger.debug("no");
|
||||
expect(console.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
62
webapp-next/src/lib/logger.ts
Normal file
62
webapp-next/src/lib/logger.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Frontend logger with configurable level (window.__DT_LOG_LEVEL).
|
||||
* Ported from webapp/js/logger.js.
|
||||
*/
|
||||
|
||||
const LEVEL_ORDER: Record<string, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
function getLogLevel(): string {
|
||||
if (typeof window === "undefined") return "info";
|
||||
const raw = (window as unknown as { __DT_LOG_LEVEL?: string }).__DT_LOG_LEVEL ?? "info";
|
||||
const level = String(raw).toLowerCase();
|
||||
return Object.hasOwn(LEVEL_ORDER, level) ? level : "info";
|
||||
}
|
||||
|
||||
function shouldLog(messageLevel: string): boolean {
|
||||
const configured = getLogLevel();
|
||||
const configuredNum = LEVEL_ORDER[configured] ?? 1;
|
||||
const messageNum = LEVEL_ORDER[messageLevel] ?? 1;
|
||||
return messageNum >= configuredNum;
|
||||
}
|
||||
|
||||
const PREFIX = "[DutyTeller]";
|
||||
|
||||
function logAt(level: string, args: unknown[]): void {
|
||||
if (!shouldLog(level)) return;
|
||||
const consoleMethod =
|
||||
level === "debug"
|
||||
? console.debug
|
||||
: level === "info"
|
||||
? console.info
|
||||
: level === "warn"
|
||||
? console.warn
|
||||
: console.error;
|
||||
const prefix = `${PREFIX}[${level}]`;
|
||||
if (args.length === 0) {
|
||||
(consoleMethod as (a: string) => void)(prefix);
|
||||
} else if (args.length === 1) {
|
||||
(consoleMethod as (a: string, b: unknown) => void)(prefix, args[0]);
|
||||
} else {
|
||||
(consoleMethod as (a: string, ...b: unknown[]) => void)(prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(msg: unknown, ...args: unknown[]): void {
|
||||
logAt("debug", [msg, ...args]);
|
||||
},
|
||||
info(msg: unknown, ...args: unknown[]): void {
|
||||
logAt("info", [msg, ...args]);
|
||||
},
|
||||
warn(msg: unknown, ...args: unknown[]): void {
|
||||
logAt("warn", [msg, ...args]);
|
||||
},
|
||||
error(msg: unknown, ...args: unknown[]): void {
|
||||
logAt("error", [msg, ...args]);
|
||||
},
|
||||
};
|
||||
36
webapp-next/src/lib/phone-format.test.ts
Normal file
36
webapp-next/src/lib/phone-format.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Unit tests for formatPhoneDisplay. Ported from webapp/js/contactHtml.test.js.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatPhoneDisplay } from "./phone-format";
|
||||
|
||||
describe("formatPhoneDisplay", () => {
|
||||
it("formats 11-digit number starting with 7", () => {
|
||||
expect(formatPhoneDisplay("79146522209")).toBe("+7 914 652-22-09");
|
||||
expect(formatPhoneDisplay("+79146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("formats 11-digit number starting with 8", () => {
|
||||
expect(formatPhoneDisplay("89146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("formats 10-digit number as Russian", () => {
|
||||
expect(formatPhoneDisplay("9146522209")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("returns empty string for null or empty", () => {
|
||||
expect(formatPhoneDisplay(null)).toBe("");
|
||||
expect(formatPhoneDisplay("")).toBe("");
|
||||
expect(formatPhoneDisplay(" ")).toBe("");
|
||||
});
|
||||
|
||||
it("strips non-digits before formatting", () => {
|
||||
expect(formatPhoneDisplay("+7 (914) 652-22-09")).toBe("+7 914 652-22-09");
|
||||
});
|
||||
|
||||
it("returns digits as-is for non-10/11 length", () => {
|
||||
expect(formatPhoneDisplay("123")).toBe("123");
|
||||
expect(formatPhoneDisplay("12345678901")).toBe("12345678901");
|
||||
});
|
||||
});
|
||||
38
webapp-next/src/lib/phone-format.ts
Normal file
38
webapp-next/src/lib/phone-format.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Phone number formatting for display.
|
||||
* Ported from webapp/js/contactHtml.js (formatPhoneDisplay).
|
||||
*
|
||||
* Russian format: 79146522209 -> +7 914 652-22-09.
|
||||
* Accepts 10 digits (9XXXXXXXXX), 11 digits (79XXXXXXXXX or 89XXXXXXXXX).
|
||||
* Other lengths are returned as-is (digits only).
|
||||
*/
|
||||
export function formatPhoneDisplay(phone: string | null | undefined): string {
|
||||
if (phone == null || String(phone).trim() === "") return "";
|
||||
const digits = String(phone).replace(/\D/g, "");
|
||||
if (digits.length === 10) {
|
||||
return (
|
||||
"+7 " +
|
||||
digits.slice(0, 3) +
|
||||
" " +
|
||||
digits.slice(3, 6) +
|
||||
"-" +
|
||||
digits.slice(6, 8) +
|
||||
"-" +
|
||||
digits.slice(8)
|
||||
);
|
||||
}
|
||||
if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) {
|
||||
const rest = digits.slice(1);
|
||||
return (
|
||||
"+7 " +
|
||||
rest.slice(0, 3) +
|
||||
" " +
|
||||
rest.slice(3, 6) +
|
||||
"-" +
|
||||
rest.slice(6, 8) +
|
||||
"-" +
|
||||
rest.slice(8)
|
||||
);
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
53
webapp-next/src/lib/telegram-ready.test.ts
Normal file
53
webapp-next/src/lib/telegram-ready.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Unit tests for callMiniAppReadyOnce: single-call behaviour and missing SDK.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const isAvailableFn = vi.fn().mockReturnValue(true);
|
||||
|
||||
vi.mock("@telegram-apps/sdk-react", () => {
|
||||
const readyFn = vi.fn();
|
||||
return {
|
||||
miniAppReady: Object.assign(readyFn, {
|
||||
isAvailable: () => isAvailableFn(),
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("callMiniAppReadyOnce", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isAvailableFn.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("calls miniAppReady at most once per session", async () => {
|
||||
vi.resetModules();
|
||||
const { callMiniAppReadyOnce } = await import("./telegram-ready");
|
||||
const { miniAppReady } = await import("@telegram-apps/sdk-react");
|
||||
const readyCall = miniAppReady as ReturnType<typeof vi.fn>;
|
||||
|
||||
callMiniAppReadyOnce();
|
||||
callMiniAppReadyOnce();
|
||||
expect(readyCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not throw when SDK is unavailable", async () => {
|
||||
isAvailableFn.mockReturnValue(false);
|
||||
vi.resetModules();
|
||||
const { callMiniAppReadyOnce } = await import("./telegram-ready");
|
||||
|
||||
expect(() => callMiniAppReadyOnce()).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not call miniAppReady when isAvailable is false", async () => {
|
||||
isAvailableFn.mockReturnValue(false);
|
||||
vi.resetModules();
|
||||
const { callMiniAppReadyOnce } = await import("./telegram-ready");
|
||||
const { miniAppReady } = await import("@telegram-apps/sdk-react");
|
||||
const readyCall = miniAppReady as ReturnType<typeof vi.fn>;
|
||||
|
||||
callMiniAppReadyOnce();
|
||||
expect(readyCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
25
webapp-next/src/lib/telegram-ready.ts
Normal file
25
webapp-next/src/lib/telegram-ready.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Single-call wrapper for Telegram Mini App ready().
|
||||
* Called once when the first visible screen has finished loading so Telegram
|
||||
* hides its native loading animation only after our content is ready.
|
||||
*/
|
||||
|
||||
import { miniAppReady } from "@telegram-apps/sdk-react";
|
||||
|
||||
let readyCalled = false;
|
||||
|
||||
/**
|
||||
* Calls Telegram miniAppReady() at most once per session.
|
||||
* Safe when SDK is unavailable (e.g. non-Telegram environment).
|
||||
*/
|
||||
export function callMiniAppReadyOnce(): void {
|
||||
if (readyCalled) return;
|
||||
try {
|
||||
if (miniAppReady.isAvailable()) {
|
||||
miniAppReady();
|
||||
readyCalled = true;
|
||||
}
|
||||
} catch {
|
||||
// SDK not available or not in Mini App context; no-op.
|
||||
}
|
||||
}
|
||||
20
webapp-next/src/lib/utils.test.ts
Normal file
20
webapp-next/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Unit tests for utils. Ported from webapp/js/utils.test.js (cn-style helpers).
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cn } from "./utils";
|
||||
|
||||
describe("cn", () => {
|
||||
it("merges class names", () => {
|
||||
expect(cn("a", "b")).toBe("a b");
|
||||
});
|
||||
|
||||
it("handles conditional classes", () => {
|
||||
expect(cn("base", false && "hidden", "visible")).toBe("base visible");
|
||||
});
|
||||
|
||||
it("merges tailwind classes correctly", () => {
|
||||
expect(cn("px-2 py-1", "px-4")).toBe("py-1 px-4");
|
||||
});
|
||||
});
|
||||
6
webapp-next/src/lib/utils.ts
Normal file
6
webapp-next/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user