Add data model types, datetime utils, and dictionary hook
Port Angular flight types (ISimpleFlight, IFlightLeg, ITimesSet, etc.) to minimal React-friendly interfaces. Add formatDuration/formatTime/ formatDate/isDayChange as pure functions. Stub useCityName hook as passthrough pending customer dictionary API endpoint.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatDuration, formatTime, formatDate, isDayChange } from "./index.js";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("formats zero minutes", () => {
|
||||
expect(formatDuration(0)).toBe("0h 0m");
|
||||
});
|
||||
|
||||
it("formats minutes only", () => {
|
||||
expect(formatDuration(45)).toBe("0h 45m");
|
||||
});
|
||||
|
||||
it("formats hours and minutes", () => {
|
||||
expect(formatDuration(150)).toBe("2h 30m");
|
||||
});
|
||||
|
||||
it("formats days, hours, and minutes", () => {
|
||||
// 1 day + 5 hours + 12 minutes = 1572 minutes
|
||||
expect(formatDuration(1572)).toBe("1d 2h 12m");
|
||||
});
|
||||
|
||||
it("formats in Russian locale", () => {
|
||||
expect(formatDuration(150, "ru")).toBe("2ч 30м");
|
||||
});
|
||||
|
||||
it("formats days in Russian locale", () => {
|
||||
expect(formatDuration(1572, "ru")).toBe("1д 2ч 12м");
|
||||
});
|
||||
|
||||
it("returns 'Unknown' for negative values", () => {
|
||||
expect(formatDuration(-1)).toBe("Unknown");
|
||||
});
|
||||
|
||||
it("returns 'Неизвестно' for negative values in Russian", () => {
|
||||
expect(formatDuration(-1, "ru")).toBe("Неизвестно");
|
||||
});
|
||||
|
||||
it("handles exact hour boundaries", () => {
|
||||
expect(formatDuration(60)).toBe("1h 0m");
|
||||
expect(formatDuration(120)).toBe("2h 0m");
|
||||
});
|
||||
|
||||
it("handles exact day boundaries", () => {
|
||||
expect(formatDuration(1440)).toBe("1d 0h 0m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("formats ISO string to HH:mm", () => {
|
||||
// Use a fixed date with explicit time to avoid timezone issues
|
||||
const d = new Date(2025, 0, 15, 10, 30);
|
||||
expect(formatTime(d)).toBe("10:30");
|
||||
});
|
||||
|
||||
it("pads single-digit hours and minutes", () => {
|
||||
const d = new Date(2025, 0, 15, 3, 5);
|
||||
expect(formatTime(d)).toBe("03:05");
|
||||
});
|
||||
|
||||
it("handles midnight", () => {
|
||||
const d = new Date(2025, 0, 15, 0, 0);
|
||||
expect(formatTime(d)).toBe("00:00");
|
||||
});
|
||||
|
||||
it("returns empty string for invalid date", () => {
|
||||
expect(formatTime("not-a-date")).toBe("");
|
||||
});
|
||||
|
||||
it("formats Date objects", () => {
|
||||
const d = new Date(2025, 0, 15, 14, 45);
|
||||
expect(formatTime(d)).toBe("14:45");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("formats a date in English", () => {
|
||||
const result = formatDate(new Date(2025, 0, 15), "en");
|
||||
expect(result).toContain("January");
|
||||
expect(result).toContain("15");
|
||||
expect(result).toContain("2025");
|
||||
});
|
||||
|
||||
it("formats a date in Russian", () => {
|
||||
const result = formatDate(new Date(2025, 0, 15), "ru");
|
||||
expect(result).toContain("15");
|
||||
expect(result).toContain("2025");
|
||||
// Russian month name for January contains "январ"
|
||||
expect(result.toLowerCase()).toContain("январ");
|
||||
});
|
||||
|
||||
it("returns empty string for invalid date", () => {
|
||||
expect(formatDate("invalid")).toBe("");
|
||||
});
|
||||
|
||||
it("defaults to English locale", () => {
|
||||
const result = formatDate(new Date(2025, 0, 15));
|
||||
expect(result).toContain("January");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDayChange", () => {
|
||||
it("returns 0 for same day", () => {
|
||||
expect(
|
||||
isDayChange("2025-01-15T10:00:00", "2025-01-15T23:00:00"),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 1 for next day", () => {
|
||||
expect(
|
||||
isDayChange(
|
||||
new Date(2025, 0, 15, 23, 0),
|
||||
new Date(2025, 0, 16, 1, 0),
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("returns -1 for previous day", () => {
|
||||
expect(
|
||||
isDayChange(
|
||||
new Date(2025, 0, 16, 1, 0),
|
||||
new Date(2025, 0, 15, 23, 0),
|
||||
),
|
||||
).toBe(-1);
|
||||
});
|
||||
|
||||
it("returns 0 for invalid dates", () => {
|
||||
expect(isDayChange("invalid", "also-invalid")).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 2 for two days later", () => {
|
||||
expect(
|
||||
isDayChange(
|
||||
new Date(2025, 0, 15),
|
||||
new Date(2025, 0, 17),
|
||||
),
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Datetime utility functions.
|
||||
*
|
||||
* Pure functions ported from Angular pipes (DurationPipe, DatePipe).
|
||||
* No Angular dependencies, no side effects.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a duration given in total minutes into a human-readable string.
|
||||
*
|
||||
* @example formatDuration(150) => "2h 30m"
|
||||
* @example formatDuration(150, "ru") => "2ч 30м"
|
||||
* @example formatDuration(0) => "0h 0m"
|
||||
*/
|
||||
export function formatDuration(
|
||||
minutes: number,
|
||||
locale: string = "en",
|
||||
): string {
|
||||
if (minutes < 0) return locale === "ru" ? "Неизвестно" : "Unknown";
|
||||
|
||||
const days = Math.floor(minutes / (60 * 24));
|
||||
const hours = Math.floor((minutes % (60 * 24)) / 60);
|
||||
const mins = Math.floor(minutes % 60);
|
||||
|
||||
const units =
|
||||
locale === "ru"
|
||||
? { d: "д", h: "ч", m: "м" }
|
||||
: { d: "d", h: "h", m: "m" };
|
||||
|
||||
const daysPart = days > 0 ? `${days}${units.d} ` : "";
|
||||
return `${daysPart}${hours}${units.h} ${mins}${units.m}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date/ISO string into "HH:mm" time.
|
||||
*
|
||||
* @example formatTime("2025-01-15T10:30:00") => "10:30"
|
||||
* @example formatTime(new Date(2025, 0, 15, 10, 30)) => "10:30"
|
||||
*/
|
||||
export function formatTime(date: string | Date): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
|
||||
const hours = String(d.getHours()).padStart(2, "0");
|
||||
const minutes = String(d.getMinutes()).padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date into a localized date string.
|
||||
*
|
||||
* @example formatDate("2025-01-15", "ru") => "15 января 2025 г."
|
||||
* @example formatDate("2025-01-15", "en") => "January 15, 2025"
|
||||
*/
|
||||
export function formatDate(
|
||||
date: string | Date,
|
||||
locale: string = "en",
|
||||
): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
|
||||
return d.toLocaleDateString(locale === "ru" ? "ru-RU" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the day offset between two dates (ignoring time).
|
||||
* Returns 0 if same day, +1 if actual is the next day, etc.
|
||||
*
|
||||
* @example isDayChange("2025-01-15T23:00", "2025-01-16T01:00") => 1
|
||||
*/
|
||||
export function isDayChange(
|
||||
scheduledDate: string | Date,
|
||||
actualDate: string | Date,
|
||||
): number {
|
||||
const s = typeof scheduledDate === "string" ? new Date(scheduledDate) : scheduledDate;
|
||||
const a = typeof actualDate === "string" ? new Date(actualDate) : actualDate;
|
||||
|
||||
if (Number.isNaN(s.getTime()) || Number.isNaN(a.getTime())) return 0;
|
||||
|
||||
// Compare calendar dates (ignoring time)
|
||||
const sDay = new Date(s.getFullYear(), s.getMonth(), s.getDate());
|
||||
const aDay = new Date(a.getFullYear(), a.getMonth(), a.getDate());
|
||||
|
||||
const diffMs = aDay.getTime() - sDay.getTime();
|
||||
return Math.round(diffMs / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
Reference in New Issue
Block a user