/** * Utility helpers local to the AI Visibility tab. * * Nothing here should mutate or re-format data that comes from the * backend already localized (problem labels, article titles, etc.). These * helpers only produce UI chrome - ISO week strings, delta formatting, * severity → tone mapping. */ export const toIsoWeek = (date: Date = new Date()): string => { const d = new Date( Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) ); const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const weekNum = Math.ceil( ((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7 ); return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`; }; export const getCurrentWeekIso = (): string => toIsoWeek(new Date()); export const MIN_VISIBILITY_WEEK_ISO = '2026-W16'; const compareIsoWeek = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0; export const getVisibilityWeeks = (maxWeeks: number = 52): string[] => { const cap = Math.max(1, Math.min(260, maxWeeks)); const out: string[] = []; const base = new Date(); for (let i = 0; i < cap; i++) { const d = new Date(base); d.setDate(base.getDate() - i * 7); const iso = toIsoWeek(d); if (compareIsoWeek(iso, MIN_VISIBILITY_WEEK_ISO) < 0) break; out.push(iso); } return out; }; /** @deprecated use {@link getVisibilityWeeks} */ export const getLastNWeeksIso = (n: number): string[] => getVisibilityWeeks(n); export type DeltaTone = 'success' | 'critical' | undefined; /** * Describe how to render a "delta vs last week" row: * - ``null`` → no prior baseline (first week). Caller hides the row; * ``hidden: true`` flags this case. * - ``0`` → unchanged. "no change" label, neutral tone. * - ``> 0`` → improvement; tone ``success``. * - ``< 0`` → regression; tone ``critical``. */ export const formatDelta = ( value: number | null | undefined, suffix: string = '' ): { hidden: boolean; label: string; tone: DeltaTone } => { if (value === null || value === undefined) { return { hidden: true, label: '', tone: undefined }; } if (value === 0) { return { hidden: false, label: 'no change', tone: undefined }; } const sign = value > 0 ? '+' : ''; return { hidden: false, label: `${sign}${value}${suffix}`, tone: value > 0 ? 'success' : 'critical', }; }; export type SeverityTone = | 'critical' | 'warning' | 'attention' | 'info' | 'success'; export const severityToTone = (severity: string): SeverityTone => { switch ((severity || '').toLowerCase()) { case 'critical': case 'high': return 'critical'; case 'warn': case 'medium': return 'warning'; case 'low': return 'attention'; case 'info': default: return 'info'; } }; export const labelForWeek = (weekIso: string, isCurrent: boolean): string => isCurrent ? `${weekIso} (current)` : weekIso; export const faviconUrlFor = (domain: string, size: number = 32): string => `https://www.google.com/s2/favicons?domain=${encodeURIComponent( domain )}&sz=${size}`; export type SentimentTone = 'success' | 'critical' | 'attention' | 'info'; export const sentimentToTone = (sentiment: string | null): SentimentTone => { switch ((sentiment || '').toLowerCase()) { case 'positive': return 'success'; case 'negative': return 'critical'; case 'neutral': return 'info'; default: return 'attention'; } }; export type PriorityTone = 'critical' | 'warning' | 'info' | 'success'; export const priorityToTone = (score: number): PriorityTone => { if (score >= 80) return 'success'; if (score >= 60) return 'warning'; if (score >= 40) return 'info'; return 'critical'; }; export const priorityToLabel = (score: number): string => { if (score >= 80) return 'High priority'; if (score >= 60) return 'Medium priority'; if (score >= 40) return 'Low priority'; return 'Nice to have'; }; export const priorityToAccent = (score: number): string => { if (score >= 80) return '#3f7b00'; if (score >= 60) return '#b98900'; if (score >= 40) return '#2c6ecb'; return '#d72c0d'; }; export const trendToMeta = ( trend: string ): { tone: 'success' | 'critical' | undefined; arrow: string; label: string; } => { switch ((trend || '').toLowerCase()) { case 'up': return { tone: 'success', arrow: '↑', label: 'Trending up' }; case 'down': return { tone: 'critical', arrow: '↓', label: 'Trending down' }; default: return { tone: undefined, arrow: '→', label: 'Stable' }; } }; export const editedByToTone = ( editedBy: string ): 'success' | 'info' | 'attention' | 'critical' | undefined => { switch (editedBy) { case 'user': return 'info'; case 'bulk_regen': return 'attention'; case 'ai': return 'success'; default: return undefined; } }; /** * Best-effort hostname match. Returns ``true`` when ``candidate`` belongs * to the merchant's own domain - handles bare hosts, ``https://`` URLs, * ``www.`` prefixes, subdomains and trailing slashes. */ export const isOwnDomain = ( candidate: string, brandDomain: string | null ): boolean => { if (!brandDomain) return false; const normalise = (input: string): string => { try { const withProtocol = /^https?:\/\//i.test(input) ? input : `https://${input}`; return new URL(withProtocol).hostname.toLowerCase().replace(/^www\./, ''); } catch { return input.toLowerCase().replace(/^www\./, ''); } }; const own = normalise(brandDomain); const other = normalise(candidate); if (!own || !other) return false; return other === own || other.endsWith(`.${own}`); }; /** * Normalise the FAQ list from an ``ArticleDetailResponse``. */ export const normaliseFaq = ( rawFaq: | { question?: string; answer?: string; [key: string]: unknown }[] | undefined | null ): { question: string; answer: string }[] => { if (!Array.isArray(rawFaq)) return []; return rawFaq .map(entry => { const e = entry as Record; const question = String(e.question ?? e.q ?? e.title ?? '').trim(); const answer = String(e.answer ?? e.a ?? e.content ?? '').trim(); return { question, answer }; }) .filter(entry => entry.question !== '' || entry.answer !== ''); }; /** * Map a tone string to Tailwind classes for badges. Centralised so the * whole feature renders consistent colours. */ export const toneToBadgeClass = ( tone: 'success' | 'critical' | 'warning' | 'attention' | 'info' | undefined ): string => { switch (tone) { case 'success': return 'bg-green-100 text-green-800 border border-green-200'; case 'critical': return 'bg-red-100 text-red-800 border border-red-200'; case 'warning': return 'bg-yellow-100 text-yellow-800 border border-yellow-200'; case 'attention': return 'bg-orange-100 text-orange-800 border border-orange-200'; case 'info': return 'bg-blue-100 text-blue-800 border border-blue-200'; default: return 'bg-gray-100 text-gray-700 border border-gray-200'; } };