Initial commit: CI Agent competitive-intelligence monitoring app
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).
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user