FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
77 lines
2.8 KiB
TypeScript
77 lines
2.8 KiB
TypeScript
import { format, formatDistanceToNow } from "date-fns";
|
|
|
|
export function formatDateTime(iso: string | null | undefined): string {
|
|
if (!iso) return "—";
|
|
return format(new Date(iso), "MMM d, yyyy h:mm a");
|
|
}
|
|
|
|
export function formatRelative(iso: string | null | undefined): string {
|
|
if (!iso) return "—";
|
|
return formatDistanceToNow(new Date(iso), { addSuffix: true });
|
|
}
|
|
|
|
/**
|
|
* NinjaPear's funding fields (`total_raised`, a round's `amount`) come back
|
|
* as raw numeric strings (e.g. "500000000"), with no currency formatting.
|
|
* Turns that into "$500,000,000"; leaves already-formatted or non-numeric
|
|
* values (e.g. "$1M", "Undisclosed") untouched.
|
|
*/
|
|
export function formatMoney(value: string): string {
|
|
const trimmed = value.trim();
|
|
if (!trimmed || !/^\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
const amount = Number(trimmed);
|
|
if (!Number.isFinite(amount)) return trimmed;
|
|
return `$${amount.toLocaleString("en-US")}`;
|
|
}
|
|
|
|
/**
|
|
* NinjaPear's competitor/customer `name` fields sometimes come back as a
|
|
* bare URL (e.g. "https://squareup.com") rather than a company name. Turns
|
|
* that into something readable; leaves already-clean names untouched.
|
|
*/
|
|
export function companyNameFromUrl(value: string): string {
|
|
const trimmed = value.trim();
|
|
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "").replace(/^www\./i, "");
|
|
if (!withoutProtocol.includes(".")) return trimmed;
|
|
|
|
const label = withoutProtocol.split("/")[0]?.split(".")[0];
|
|
if (!label) return trimmed;
|
|
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase();
|
|
}
|
|
|
|
const ENRICHMENT_REASON_LABELS: Record<string, string> = {
|
|
product_overlap: "Similar products",
|
|
organic_keyword_overlap: "Overlapping search keywords",
|
|
};
|
|
|
|
/** Turns a NinjaPear competitor-match slug (e.g. "product_overlap") into a
|
|
* human-readable label; unrecognized slugs still read as words. */
|
|
export function formatEnrichmentReason(reason: string): string {
|
|
const known = ENRICHMENT_REASON_LABELS[reason];
|
|
if (known) return known;
|
|
return reason
|
|
.split("_")
|
|
.filter(Boolean)
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
/** Light heuristics to turn a raw exception string from an enrichment
|
|
* section failure into a short, non-technical summary. */
|
|
export function summarizeEnrichmentError(raw: string): string {
|
|
const firstLine = raw.split("\n")[0]?.trim() ?? raw;
|
|
if (/^\d+ validation errors?/i.test(firstLine)) {
|
|
return "The data returned didn't match the expected format.";
|
|
}
|
|
if (/404 not found/i.test(raw)) {
|
|
return "The requested data wasn't found.";
|
|
}
|
|
if (/timed? ?out/i.test(raw)) {
|
|
return "The request timed out.";
|
|
}
|
|
if (/\b5\d\d\b/.test(raw) && /error/i.test(raw)) {
|
|
return "The data provider returned a server error.";
|
|
}
|
|
return firstLine.length > 100 ? `${firstLine.slice(0, 100)}…` : firstLine;
|
|
}
|