// Shared helpers, stage maps, and backend adapters. // All API data is fetched per-view; this file only exposes pure helpers // and a small in-memory contact cache that the CommandPalette + breadcrumbs use. // ---- Stage mapping: backend stage strings -> design stage tokens ---- // Backend: NEW LEAD, FIRST TOUCH, INTERESTED, BOT ENGAGED, FOLLOW UP, // DAY PASS SCHEDULED, NEEDS_RESCHEDULE, SHOWED, JOINED, MEMBER, // DEAD, NO SHOW const STAGE_GROUP = { 'NEW LEAD': 'new', 'FIRST TOUCH': 'new', 'INTERESTED': 'contacted', 'BOT ENGAGED': 'contacted', 'FOLLOW UP': 'contacted', 'DAY PASS SCHEDULED': 'booked', 'FDP BOOKED': 'booked', // NEEDS_RESCHEDULE is its own group (amber chip) — separates contacts // waiting to rebook from active bookings without lumping them with // closed-out states like NO SHOW or DEAD. 'NEEDS_RESCHEDULE': 'reschedule', 'SHOWED': 'booked', 'JOINED': 'member', 'MEMBER': 'member', 'DEAD': 'churned', 'LOST': 'churned', 'NO SHOW': 'churned', }; function stageGroupOf(stageRaw) { const s = String(stageRaw || '').toUpperCase(); return STAGE_GROUP[s] || 'new'; } function stageLabelOf(stageRaw) { return String(stageRaw || 'NEW LEAD').toLowerCase(); } // ---- Time helpers ---- // RAW gym is in Stuart, FL. All booking/appointment times are surfaced in // gym-local time (ET) regardless of where the staff member is logged in from. // Use the IANA zone — it auto-switches between EST and EDT. const GYM_TZ = 'America/New_York'; // Extract { year, month, day, hour, minute } from a Date in GYM_TZ. // Used wherever calendar positioning / day-grouping needs gym-local fields // instead of the browser's local fields. function gymParts(d) { if (!(d instanceof Date) || isNaN(d.getTime())) return null; const parts = new Intl.DateTimeFormat('en-US', { timeZone: GYM_TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).formatToParts(d).reduce((a, p) => (a[p.type] = p.value, a), {}); return { year: parseInt(parts.year, 10), month: parseInt(parts.month, 10), day: parseInt(parts.day, 10), hour: parseInt(parts.hour, 10) % 24, // '24' → 0 at midnight in some locales minute: parseInt(parts.minute,10), }; } function gymIsoDay(d) { const p = gymParts(d); return p ? `${p.year}-${String(p.month).padStart(2,'0')}-${String(p.day).padStart(2,'0')}` : null; } function gymMinutesOfDay(d) { const p = gymParts(d); return p ? p.hour * 60 + p.minute : null; } // Return a JS Date pinned to noon UTC on the Monday of the gym-local week that // contains `now`. Noon UTC is safely mid-day in GYM_TZ regardless of DST // (gym sees 7am EDT / 8am EST), so adding/subtracting whole days never rolls // the date over an unexpected boundary. function mondayOfGymWeek(now) { const gp = gymParts(now); if (!gp) return null; const probe = new Date(Date.UTC(gp.year, gp.month - 1, gp.day, 12, 0, 0)); const wd = new Intl.DateTimeFormat('en-US', { timeZone: GYM_TZ, weekday: 'short' }).format(probe); const idx = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'].indexOf(wd); return new Date(probe.getTime() - (idx < 0 ? 0 : idx) * 86400000); } function parseDate(v) { if (!v) return null; if (v instanceof Date) return v; if (typeof v === 'number') return new Date(v); // asyncpg often returns "2026-04-21 12:00:00+00:00" or ISO const s = String(v).replace(' ', 'T'); const d = new Date(s); return isNaN(d.getTime()) ? null : d; } function minutesAgo(v) { const d = parseDate(v); if (!d) return null; return Math.max(0, Math.round((Date.now() - d.getTime()) / 60000)); } function fmtAgo(minutes) { if (minutes == null) return '—'; if (minutes < 1) return 'just now'; if (minutes < 60) return `${minutes}m ago`; const h = Math.floor(minutes / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); return `${d}d ago`; } function fmtAgoFrom(v) { return fmtAgo(minutesAgo(v)); } // Whole days since `v` (used by the lead-detail badge "Waiting to reschedule // for N days"). Returns 0 for "less than a day ago" so the badge reads // "Waiting to reschedule for 0 days" right after the click, which is more // truthful than rounding up. parseDate is permissive about ISO/ms inputs. function daysSince(v) { const d = parseDate(v); if (!d) return null; return Math.max(0, Math.floor((Date.now() - d.getTime()) / 86400000)); } function fmtMin(t) { const h = Math.floor(t / 60); const m = t % 60; const ampm = h >= 12 ? 'p' : 'a'; const h12 = ((h + 11) % 12) + 1; return m === 0 ? `${h12}${ampm}` : `${h12}:${String(m).padStart(2,'0')}${ampm}`; } function fmtDateTime(v) { const d = parseDate(v); if (!d) return '—'; const day = d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }); const time = d.toLocaleTimeString(undefined, { hour:'numeric', minute:'2-digit' }); return `${day} · ${time}`; } function fmtShortDay(v) { const d = parseDate(v); if (!d) return '—'; return d.toLocaleDateString(undefined, { weekday:'short', month:'short', day:'numeric' }); } function fmtClock(v) { const d = parseDate(v); if (!d) return '—'; return d.toLocaleTimeString(undefined, { timeZone: GYM_TZ, hour:'numeric', minute:'2-digit' }); } // ---- Avatar helpers ---- function initialsOf(nameOrEmail) { if (!nameOrEmail) return '?'; const s = String(nameOrEmail).trim(); if (s.includes(' ')) { return s.split(/\s+/).map(p => p[0]).join('').slice(0,2).toUpperCase(); } if (s.includes('@')) return s[0].toUpperCase() + (s[1] || '').toUpperCase(); return s.slice(0,2).toUpperCase(); } const PALETTE = [ 'oklch(0.68 0.18 52)', 'oklch(0.55 0.14 240)', 'oklch(0.60 0.14 160)', 'oklch(0.58 0.15 300)', 'oklch(0.62 0.14 110)', 'oklch(0.58 0.14 190)', 'oklch(0.62 0.17 20)', ]; function colorFor(key) { const s = String(key || ''); let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return PALETTE[h % PALETTE.length]; } function staffBadge(name) { return { initials: initialsOf(name), color: colorFor(name) }; } // ---- Contact cache (shared across views) ---- // Populated by LeadsView / HomeView as data comes in so CommandPalette can // offer members without having to re-fetch. const _contactCache = new Map(); function cacheContacts(list) { (list || []).forEach(c => { if (c && c.id) _contactCache.set(c.id, c); }); } function cachedContacts() { return Array.from(_contactCache.values()); } function contactById(id) { return _contactCache.get(id) || null; } // ---- Lead-row adapter (backend -> design shape) ---- function adaptLead(raw) { if (!raw) return null; const group = stageGroupOf(raw.stage); return { ...raw, id: raw.id, name: raw.name || 'Unknown', phone: raw.phone || '', email: raw.email || '', stage: group, stage_raw: raw.stage || 'NEW LEAD', source: raw.source || '', interest: (raw.tags || []).slice(0, 3).map(t => String(t).replace(/-/g, ' ')).join(', ') || (raw.stage || '').toLowerCase(), owner: null, hot: !!raw.hot, is_member: !!raw.is_member, tommy_contacted: !!raw.tommy_contacted, tommy_status: raw._has_pending_approval ? 'pending_approval' : raw._has_unread_inbound ? 'unanswered' : 'idle', scheduled_at: raw.scheduled_at || null, scheduled_label: raw.scheduled_at ? fmtDateTime(raw.scheduled_at) : (raw.scheduled_for || null), lastActivity: raw.updated_at ? minutesAgo(raw.updated_at) : (raw.age_days ? raw.age_days * 1440 : null), age_days: raw.age_days || 0, }; } // ---- Friendly error translation ---- // The API returns 502 when GoHighLevel is unreachable (see quick-wins 2026-04). // Surfacing the raw FastAPI detail string confuses staff, so normalize by status. function friendlyApiError(e, fallback) { const msg = fallback || 'Request failed'; if (!e) return msg; const status = e.status; if (status === 502) return 'GoHighLevel is unreachable right now. Your change did not sync — try again in a moment.'; if (status === 401) return 'Your session expired. Sign in again.'; if (status === 403) return 'You don’t have access to that action.'; if (status === 429) return 'Too many requests. Wait a few seconds and retry.'; if (status >= 500) return msg + '. Backend responded ' + status + '.'; return e.message || msg; } // Quiet-hours 409 from /api/v2/tommy/approve and /api/v2/leads//message. // FastAPI nests the structured error under .detail; the api client preserves // the full payload on err.payload. function isQuietHoursError(e) { return e && e.status === 409 && e.payload && e.payload.detail && e.payload.detail.error === 'quiet_hours'; } function quietHoursPrompt(e, action) { const d = (e && e.payload && e.payload.detail) || {}; const where = d.timezone ? ` (${d.timezone})` : ''; const when = d.local_time ? ` It is ${d.local_time} for them${where}.` : ''; return ( `Quiet-hours policy: we don't send between 8 PM and 8 AM in the recipient's timezone.` + when + `\n\nOverride and ${action} anyway?` ); } Object.assign(window, { stageGroupOf, stageLabelOf, STAGE_GROUP, parseDate, minutesAgo, fmtAgo, fmtAgoFrom, fmtMin, daysSince, fmtDateTime, fmtShortDay, fmtClock, initialsOf, colorFor, staffBadge, cacheContacts, cachedContacts, contactById, adaptLead, friendlyApiError, isQuietHoursError, quietHoursPrompt, });