src/lib/utils.ts
Rama main · réplica de lectura del repositorio original.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { daysBetween, SERVER_ZONE } from "@/lib/time";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function initials(name: string) {
return (
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("") || "?"
);
}
/** "2021-03" | "2021-03-01" | "Mar 2021" -> "Mar 2021" */
export function formatMonth(value?: string | null) {
if (!value) return "";
const trimmed = value.trim();
if (!trimmed) return "";
const iso = /^(\d{4})-(\d{2})(-\d{2})?$/.exec(trimmed);
if (iso) {
const d = new Date(Number(iso[1]), Number(iso[2]) - 1, 1);
return d.toLocaleDateString("en-US", { month: "short", year: "numeric" });
}
if (/^\d{4}$/.test(trimmed)) return trimmed;
return trimmed;
}
export function dateRange(start?: string | null, end?: string | null, current?: boolean) {
const s = formatMonth(start);
const e = current ? "Present" : formatMonth(end);
if (s && e) return `${s} – ${e}`;
return s || e || "";
}
/**
* "Today", "Tomorrow", "3d overdue" — a date read as a calendar, not a clock.
*
* `timeZone` is whose calendar. In the browser it can be left off, because the
* host clock IS the reader's; on the server it must be passed or "today" means
* today in whatever zone the instance runs in, which on a hosted one is UTC.
* See src/lib/time.ts.
*/
export function relativeDay(
date: Date | string | null | undefined,
timeZone: string = SERVER_ZONE,
) {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
const days = daysBetween(new Date(), d, timeZone);
if (days === 0) return "Today";
if (days === 1) return "Tomorrow";
if (days === -1) return "Yesterday";
if (days < 0) return `${Math.abs(days)}d overdue`;
return `in ${days}d`;
}
/** Past-tense counterpart to relativeDay: how long since something happened. */
export function agoDay(date: Date | string | null | undefined, timeZone: string = SERVER_ZONE) {
if (!date) return "";
const d = typeof date === "string" ? new Date(date) : date;
const days = daysBetween(d, new Date(), timeZone);
if (days <= 0) return "Today";
if (days === 1) return "Yesterday";
if (days < 14) return `${days}d ago`;
if (days < 61) return `${Math.round(days / 7)}w ago`;
return `${Math.round(days / 30.4)}mo ago`;
}
export function slugify(input: string) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")
.slice(0, 60);
}
export function truncate(text: string, max = 160) {
if (text.length <= max) return text;
return `${text.slice(0, max - 1).trimEnd()}…`;
}
/**
* What a stage checklist just put on somebody's list, for the toast.
*
* Named rather than counted. Tasks appearing from nowhere read as a bug, and
* "Added: Send a thank-you" is the line that makes the feature legible the
* first time it fires. Undefined when nothing fired, so the toast stays a
* single line in the ordinary case.
*/
export function describeAdded(titles: string[]): string | undefined {
if (titles.length === 0) return undefined;
if (titles.length <= 2) return `Added: ${titles.join(", ")}`;
return `Added ${titles.length} tasks: ${titles.slice(0, 2).join(", ")}, and ${titles.length - 2} more`;
}