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,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
View 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();
}
}

View 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({});
});
});

View 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;
}

View 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;

View 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();
});
});

View 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;
}

View 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");
});
});

View 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;
}

View 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");
});
});

View 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,
};
});
}

View 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();
});
});

View 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;
}

View 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();
});
});

View 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]);
},
};

View 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");
});
});

View 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;
}

View 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();
});
});

View 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.
}
}

View 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");
});
});

View 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));
}