264 lines
8.1 KiB
TypeScript
264 lines
8.1 KiB
TypeScript
/**
|
|
* Use parseLocalDate when you need a Date object at local midnight
|
|
* (for calendars, date pickers, Date math in the browser).
|
|
*
|
|
*
|
|
* Parse a date string in yyyy-MM-dd format (assumed local) into a JS Date object.
|
|
* No timezone conversion is applied. Returns a Date at midnight local time.
|
|
*
|
|
* * Accepts:
|
|
* - "YYYY-MM-DD"
|
|
* - ISO/timestamp string (will take left-of-'T' date portion)
|
|
* - Date object (will return a new Date set to that local calendar day at midnight)
|
|
*/
|
|
|
|
export function parseLocalDate(input: string | Date): Date {
|
|
if (input instanceof Date) {
|
|
return new Date(input.getFullYear(), input.getMonth(), input.getDate());
|
|
}
|
|
|
|
if (typeof input === "string") {
|
|
const dateString = input?.split("T")[0] ?? "";
|
|
const parts = dateString.split("-");
|
|
|
|
const [yearStr, monthStr, dayStr] = parts;
|
|
|
|
// Validate all parts are defined and valid strings
|
|
if (!yearStr || !monthStr || !dayStr) {
|
|
throw new Error("Invalid date string format. Expected yyyy-MM-dd.");
|
|
}
|
|
|
|
const year = parseInt(yearStr, 10);
|
|
const month = parseInt(monthStr, 10) - 1; // JS Date months are 0-based
|
|
const day = parseInt(dayStr, 10);
|
|
|
|
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) {
|
|
throw new Error("Invalid numeric values in date string.");
|
|
}
|
|
|
|
return new Date(year, month, day);
|
|
}
|
|
throw new Error(
|
|
"Unsupported input to parseLocalDate. Expected string or Date."
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Use formatLocalDate when you need a date-only string "YYYY-MM-DD" (for displaying stable date values in UI lists,
|
|
* sending to APIs, storing in sessionStorage/DB where date-only is required).
|
|
*
|
|
*
|
|
* Format a date value into a "YYYY-MM-DD" string with **no timezone shifts**.
|
|
*
|
|
* Handles all common input cases:
|
|
* - "YYYY-MM-DD" string → returned as-is.
|
|
* - ISO/timestamp string → takes the date portion before "T" (safe, no TZ math).
|
|
* - Date object:
|
|
* - If created via `new Date("2025-07-15T00:00:00Z")` (ISO instant),
|
|
* UTC vs local calendar components may differ. In this case, use UTC
|
|
* fields so the original calendar day (15th) is preserved across timezones.
|
|
* - If created via `parseLocalDate("2025-07-15")` or `new Date(2025, 6, 15)`
|
|
* (local-midnight Date), UTC and local calendar components match,
|
|
* so local fields are safe to use.
|
|
*
|
|
* This hybrid logic ensures:
|
|
* - DOBs and other date-only values will never appear off by one day
|
|
* due to timezone differences.
|
|
* - Works with both string and Date inputs without requiring code changes elsewhere.
|
|
*/
|
|
export function formatLocalDate(input?: string | Date): string {
|
|
if (!input) return "";
|
|
|
|
// Case 1: already "YYYY-MM-DD" string
|
|
if (typeof input === "string" && /^\d{4}-\d{2}-\d{2}$/.test(input)) {
|
|
return input;
|
|
}
|
|
|
|
// Case 2: ISO/timestamp string -> take the left-of-T portion
|
|
if (typeof input === "string") {
|
|
const dateString = input.split("T")[0] ?? "";
|
|
return dateString;
|
|
}
|
|
|
|
// Case 3: Date object
|
|
if (input instanceof Date) {
|
|
if (isNaN(input.getTime())) return "";
|
|
|
|
// ALWAYS use the local calendar fields for Date objects.
|
|
// This avoids day-flips when a Date was constructed from an ISO instant
|
|
// and the browser's timezone would otherwise show a different calendar day.
|
|
const y = input.getFullYear();
|
|
const m = `${input.getMonth() + 1}`.padStart(2, "0");
|
|
const d = `${input.getDate()}`.padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
// ---------- helpers ----------
|
|
const MONTH_SHORT = [
|
|
"Jan",
|
|
"Feb",
|
|
"Mar",
|
|
"Apr",
|
|
"May",
|
|
"Jun",
|
|
"Jul",
|
|
"Aug",
|
|
"Sep",
|
|
"Oct",
|
|
"Nov",
|
|
"Dec",
|
|
];
|
|
|
|
function isDateOnlyString(s: string): boolean {
|
|
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
}
|
|
|
|
// ---------- formatDateToHumanReadable ----------
|
|
/**
|
|
* Frontend-safe human readable formatter.
|
|
*
|
|
* Rules:
|
|
* - If input is a date-only string "YYYY-MM-DD", format it directly (no TZ math).
|
|
* - If input is a Date object, use its local calendar fields (getFullYear/getMonth/getDate).
|
|
* - If input is any other string (ISO/timestamp), DO NOT call new Date(isoString) directly
|
|
* for display. Instead, use parseLocalDate(dateInput) to extract the local calendar day
|
|
* (strip time portion) and render that. This prevents off-by-one day drift.
|
|
*
|
|
* Output example: "Oct 7, 2025"
|
|
*/
|
|
export function formatDateToHumanReadable(dateInput?: string | Date): string {
|
|
if (!dateInput) return "N/A";
|
|
|
|
// date-only string -> show as-is using MONTH_SHORT
|
|
if (typeof dateInput === "string" && isDateOnlyString(dateInput)) {
|
|
const [y, m, d] = dateInput.split("-");
|
|
if (!y || !m || !d) return "Invalid Date";
|
|
return `${MONTH_SHORT[parseInt(m, 10) - 1]} ${d}, ${y}`;
|
|
}
|
|
|
|
// Date object -> use local calendar fields
|
|
if (dateInput instanceof Date) {
|
|
if (isNaN(dateInput.getTime())) return "Invalid Date";
|
|
const dd = String(dateInput.getDate());
|
|
const mm = MONTH_SHORT[dateInput.getMonth()];
|
|
const yy = dateInput.getFullYear();
|
|
return `${mm} ${dd}, ${yy}`;
|
|
}
|
|
|
|
// Other string (likely ISO/timestamp) -> normalize via parseLocalDate
|
|
// This preserves the calendar day the user expects (no timezone drift).
|
|
if (typeof dateInput === "string") {
|
|
try {
|
|
const localDate = parseLocalDate(dateInput);
|
|
const dd = String(localDate.getDate());
|
|
const mm = MONTH_SHORT[localDate.getMonth()];
|
|
const yy = localDate.getFullYear();
|
|
return `${mm} ${dd}, ${yy}`;
|
|
} catch (err) {
|
|
console.error("Invalid date input provided:", dateInput, err);
|
|
return "Invalid Date";
|
|
}
|
|
}
|
|
|
|
return "Invalid Date";
|
|
}
|
|
|
|
|
|
// ---------------- OCR Date helper --------------------------
|
|
/**
|
|
* Convert any OCR numeric-ish value into a number.
|
|
* Handles string | number | null | undefined gracefully.
|
|
*/
|
|
export function toNum(val: string | number | null | undefined): number {
|
|
if (val == null || val === "") return 0;
|
|
if (typeof val === "number") return val;
|
|
const parsed = Number(val);
|
|
return isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
/**
|
|
* Convert any OCR string-like value into a safe string.
|
|
*/
|
|
export function toStr(val: string | number | null | undefined): string {
|
|
if (val == null) return "";
|
|
return String(val).trim();
|
|
}
|
|
|
|
/**
|
|
* Convert OCR date strings like "070825" (MMDDYY) into a JS Date object.
|
|
* Example: "070825" → 2025-08-07.
|
|
*/
|
|
export function convertOCRDate(
|
|
input: string | number | null | undefined
|
|
): Date {
|
|
const raw = toStr(input);
|
|
|
|
if (!/^\d{6}$/.test(raw)) {
|
|
throw new Error(`Invalid OCR date format: ${raw}`);
|
|
}
|
|
|
|
const month = parseInt(raw.slice(0, 2), 10) - 1;
|
|
const day = parseInt(raw.slice(2, 4), 10);
|
|
const year2 = parseInt(raw.slice(4, 6), 10);
|
|
const year = year2 < 50 ? 2000 + year2 : 1900 + year2;
|
|
|
|
return new Date(year, month, day);
|
|
}
|
|
|
|
/**
|
|
* Format a Date or date string into "HH:mm" (24-hour) string.
|
|
*
|
|
* Options:
|
|
* - By default, hours/minutes are taken in local time.
|
|
* - Pass { asUTC: true } to format using UTC hours/minutes.
|
|
*
|
|
* Examples:
|
|
* formatLocalTime(new Date(2025, 6, 15, 9, 5)) → "09:05"
|
|
* formatLocalTime("2025-07-15") → "00:00"
|
|
* formatLocalTime("2025-07-15T14:30:00Z") → "20:30" (in +06:00)
|
|
* formatLocalTime("2025-07-15T14:30:00Z", { asUTC:true }) → "14:30"
|
|
*/
|
|
export function formatLocalTime(
|
|
d: Date | string | undefined,
|
|
opts: { asUTC?: boolean } = {}
|
|
): string {
|
|
if (!d) return "";
|
|
|
|
const { asUTC = false } = opts;
|
|
const pad2 = (n: number) => n.toString().padStart(2, "0");
|
|
|
|
let dateObj: Date;
|
|
|
|
if (d instanceof Date) {
|
|
if (isNaN(d.getTime())) return "";
|
|
dateObj = d;
|
|
} else if (typeof d === "string") {
|
|
const raw = d.trim();
|
|
const isDateOnly = /^\d{4}-\d{2}-\d{2}$/.test(raw);
|
|
|
|
if (isDateOnly) {
|
|
// Parse yyyy-MM-dd safely as local midnight
|
|
try {
|
|
dateObj = parseLocalDate(raw);
|
|
} catch {
|
|
dateObj = new Date(raw); // fallback
|
|
}
|
|
} else {
|
|
// For full ISO/timestamp strings, let Date handle TZ
|
|
dateObj = new Date(raw);
|
|
}
|
|
|
|
if (isNaN(dateObj.getTime())) return "";
|
|
} else {
|
|
return "";
|
|
}
|
|
|
|
const hours = asUTC ? dateObj.getUTCHours() : dateObj.getHours();
|
|
const minutes = asUTC ? dateObj.getUTCMinutes() : dateObj.getMinutes();
|
|
|
|
return `${pad2(hours)}:${pad2(minutes)}`;
|
|
}
|