diff --git a/.greptile/config.json b/.greptile/config.json new file mode 100644 index 00000000..34c493dc --- /dev/null +++ b/.greptile/config.json @@ -0,0 +1,3 @@ +{ + "skipReview": "AUTOMATIC" +} diff --git a/docs/content/docs/api/reference/contacts.mdx b/docs/content/docs/api/reference/contacts.mdx index e98f6d84..a72fae15 100644 --- a/docs/content/docs/api/reference/contacts.mdx +++ b/docs/content/docs/api/reference/contacts.mdx @@ -982,7 +982,7 @@ Auth: **Scope** `READ_CRM` · **Org permission** `view_contacts` ## Segments -Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. +Segments are saved contact audiences: a list of conditions plus per-contact manual overrides. Membership is evaluated live on every read, so a segment never needs rebuilding. Every segment endpoint takes the contact scopes, except enrolling into a campaign, which writes leads and takes `WRITE_CAMPAIGNS`. Endpoints that operate on an existing segment address it by its `id`; besides `GET /segments`, the dashboard shows that ID on the segment page header (click to copy) and in the row menu of the Segments tab. A segment object: diff --git a/docs/content/docs/guides/segments.mdx b/docs/content/docs/guides/segments.mdx index c274c237..ba84cdbc 100644 --- a/docs/content/docs/guides/segments.mdx +++ b/docs/content/docs/guides/segments.mdx @@ -52,6 +52,12 @@ Sequences can pin as well: the **Add to segment** and **Remove from segment** ac - **Duplicate**: the segment menu copies a definition to start a variation from. - **Search and export**: the contact search and export accept `segment_ids`, so anything that takes a contact filter can be scoped to a segment. +## Segments in the API + +Everything above can be driven from the [API](/api/reference/contacts/#segments): list, create, update and delete segments, pin contacts in or out, look up a contact's segments, and enrol a segment into a campaign. Contact search and export take `segment_ids` to scope any contact query to a segment, so an external system (a signup form, a CRM sync) can keep a segment current and let campaigns pick it up from there. + +API calls address a segment by its ID. It is shown at the bottom of the segment page header (click it to copy), in the **Copy segment ID** entry of a segment's row menu on the Segments tab, and in every segment the API returns. Reads take the `READ_CONTACTS` key scope, writes `WRITE_CONTACTS`, and enrolling into a campaign `WRITE_CAMPAIGNS`. + Categories are labels you put on a contact. Segments are rules that read those labels (and everything else) to decide who belongs. Use a category to mark a fact about a contact, and a segment to describe an audience. @@ -68,4 +74,5 @@ Categories are labels you put on a contact. Segments are rules that read those l + diff --git a/web/src/app/app/contacts/segments/[id]/page.tsx b/web/src/app/app/contacts/segments/[id]/page.tsx index 827e829b..2e41856a 100644 --- a/web/src/app/app/contacts/segments/[id]/page.tsx +++ b/web/src/app/app/contacts/segments/[id]/page.tsx @@ -3,7 +3,7 @@ import React from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { ArrowLeftIcon, ChevronDownIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { ArrowLeftIcon, CheckIcon, ChevronDownIcon, CopyIcon, MegaphoneIcon, PencilIcon, Trash2Icon } from "lucide-react"; import toast from "react-hot-toast"; import ContactsTable from "@/components/app/contacts/ContactsTable"; @@ -94,6 +94,7 @@ function SegmentDetail() { {s.description &&

{s.description}

} +
+ + {copied ? "Segment ID copied" : ""} + + + ); +} + // Pinned contacts. Excluded ones never show in the member list, so this is // the only place they can be seen and released. function OverridesPanel({ segment }: { segment: Segment }) { diff --git a/web/src/app/app/contacts/segments/page.tsx b/web/src/app/app/contacts/segments/page.tsx index d16cd008..8160cb09 100644 --- a/web/src/app/app/contacts/segments/page.tsx +++ b/web/src/app/app/contacts/segments/page.tsx @@ -92,6 +92,16 @@ function SegmentsList() { setEditorOpen(true); } + // The ID is what the segments API takes; copying needs no write permission. + async function copyId(s: Segment) { + try { + await navigator.clipboard.writeText(s.id); + toast.success("Segment ID copied"); + } catch { + toast.error("Could not copy"); + } + } + function askDelete(s: Segment) { confirm.show(`Delete the segment "${s.name}"? Contacts themselves are kept.`, async () => { try { @@ -202,6 +212,7 @@ function SegmentsList() { openEdit(s))}>Edit conditions setCampaignFor(s))}>Add to campaign duplicate(s))}>Duplicate + copyId(s)}>Copy segment ID askDelete(s))}>Delete diff --git a/web/src/components/layout/AppNav.tsx b/web/src/components/layout/AppNav.tsx index 8c082410..6779b333 100644 --- a/web/src/components/layout/AppNav.tsx +++ b/web/src/components/layout/AppNav.tsx @@ -605,31 +605,32 @@ function Section({ section, first = false }: { section: NavSection; first?: bool * Anatomy: * * ┌──────────────────────────────────┐ - * │ ● LIVE 42 of 50 / day │ ← status dot + label + cap pace - * │ 8 mailboxes sending now │ ← mailbox composition - * │ ▂▃▅▇▆▄▂ ▂▃▅▇▆▄▂ │ ← optional 24h sparkline + * │ 128 of 400 sent today │ ← hero number (scrubs on hover) + * │ ━━━━━━━───────── │ ← capacity meter (today vs cap) + * │ ∿∿∿∿∿∿ │ ← 14-day area sparkline + * │ ✉ 8 ● 5 ⬇ 3 │ ← mailboxes · active · unread * └──────────────────────────────────┘ * - * Reads as ambient telemetry: even when idle, it tells you "system is - * up, n mailboxes ready." Clicking jumps to analytics. The dot pulses - * when at least one mailbox is actively warming or sending. + * Reads as ambient telemetry: even when idle, it tells you "n mailboxes, + * n sent today." Clicking jumps to analytics; hovering a day on the + * sparkline swaps the hero number to that day. There is deliberately no + * LIVE/OFFLINE status row: the numbers ticking realtime already say the + * system is up, so the panel spends its pixels on the data instead. * * Data sources at this layer: * - useAppStore.emails → mailbox count, active count - * - useAppStore.connectionStatus → online/offline state * - useDashboard("30d") daily_trend → today's sent volume + the sparkline * (shares the dashboard page's query cache; realtime invalidation keeps * it current) * - * The capacity denominator is a derived cap based on mailbox count × 50 - * (default cold cap from internal/config/constants.go). + * The capacity denominator sums each mailbox's configured campaign_limit + * (default 50/day, from internal/config/constants.go). */ function LivePanel() { const emails = useAppStore((s) => s.emails); - const connection = useAppStore((s) => s.connectionStatus); - const latencyMs = useAppStore((s) => s.wsLatencyMs); const unseenCount = useAppStore((s) => s.unseenCount); const dash = useDashboard("30d"); + const [hovered, setHovered] = useState(null); const { active, mailboxes, capacity } = useMemo(() => { const m = emails.length; @@ -637,167 +638,283 @@ function LivePanel() { const st = mailboxDisplayStatus(e); return st === "healthy" || st === "warming"; }).length; - return { active: a, mailboxes: m, capacity: m * 50 }; + // Capacity = the sum of each mailbox's configured daily campaign + // limit (default 50/day), not a flat count × 50 — a tuned-down or + // raised mailbox should move the meter's denominator. + const cap = emails.reduce((sum, e) => sum + (e.campaign_limit ?? 50), 0); + return { active: a, mailboxes: m, capacity: cap }; }, [emails]); const { sentToday, trend } = useMemo(() => { - const days = dash.data?.daily_trend ?? []; - const todayKey = new Date().toISOString().slice(0, 10); - const today = days.find((d) => d.date?.slice(0, 10) === todayKey); - return { - sentToday: today?.sent ?? 0, - trend: days.slice(-14).map((d) => d.sent), - }; + // daily_trend only contains days that had sends; rebuild a continuous + // last-14-days axis (zero-filling the gaps) so the sparkline's x + // spacing is honest — otherwise a quiet week would be silently + // squeezed out and two distant days would read as adjacent. + const byDate = new Map( + (dash.data?.daily_trend ?? []).map((d) => [d.date?.slice(0, 10), d.sent]), + ); + const out: { date: string; sent: number }[] = []; + const now = new Date(); + for (let i = 13; i >= 0; i--) { + const d = new Date(now); + d.setUTCDate(now.getUTCDate() - i); + const key = d.toISOString().slice(0, 10); + out.push({ date: key, sent: byDate.get(key) ?? 0 }); + } + return { sentToday: out[out.length - 1].sent, trend: out }; }, [dash.data]); - const live = connection === "connected"; - // Connected == green, always. When quiet we say READY (not the old "IDLE", - // which with a gray dot read as "not connected"); when a mailbox is warming - // or sending we say LIVE and pulse. Only a real disconnect is gray. - const label = - connection === "disconnected" - ? "OFFLINE" - : connection === "connecting" - ? "CONNECTING" - : active > 0 - ? "LIVE" - : "READY"; - const dotClass = - connection === "disconnected" - ? "bg-slate-300" - : connection === "connecting" - ? "bg-amber-500" - : "bg-emerald-500"; - const labelTone = - connection === "disconnected" - ? "text-slate-400" - : connection === "connecting" - ? "text-amber-600" - : "text-emerald-600"; - - // Latency bucketing: <100ms great, <300ms okay, ≥300ms poor. - const latencyTone = - latencyMs == null - ? "text-slate-400" - : latencyMs < 100 - ? "text-emerald-600" - : latencyMs < 300 - ? "text-amber-600" - : "text-red-500"; + const scrub = hovered != null ? trend[hovered] : undefined; + const pct = capacity > 0 ? Math.min(100, (sentToday / capacity) * 100) : 0; return ( -
- - - {/* Active mailboxes ping; a quiet-but-connected workspace gets a - slow breathing glow so "READY" reads alive, not stuck. */} - {live && active > 0 ? ( - - ) : live ? ( - - ) : null} - - - {label} - - - {latencyMs != null ? `${latencyMs}ms` : "—"} - -
- -
- - {mailboxes} - - - {mailboxes === 1 ? "mailbox" : "mailboxes"} - - {active > 0 && ( - - {active} active - + {/* Hero: today's sends against the derived daily cap. While the + sparkline is being scrubbed it shows the hovered day instead. */} +
+ {scrub ? ( + <> + + {scrub.sent.toLocaleString()} + + + sent {formatTrendDay(scrub.date)} + + + ) : ( + <> + + + {capacity > 0 + ? `of ${capacity.toLocaleString()} sent today` + : "sent today"} + + )}
-
- Inbox + {/* Capacity meter: same-ramp track so the unfilled part still reads + as "room left today", not as a broken bar. */} +
0 + ? `${sentToday} of ${capacity} daily capacity used` + : "Connect a mailbox to start sending" + } + > +
+
+
+
+ + + + {/* Glance chips: mailboxes · active senders · unread inbox. Icons + carry the labels (title attrs spell them out) so this stays one + quiet row instead of two label/value text lines. */} +
+ + + {mailboxes} + + {active > 0 && ( + + + {active} + + )} 0 ? "text-sky-600" : "text-slate-400", )} + title={`${unseenCount} unread in inbox`} > - {unseenCount > 99 ? "99+" : unseenCount} unread + + + {unseenCount > 99 ? "99+" : unseenCount} +
- -
- Today - 0 ? "text-slate-600" : "text-slate-400", - )} - > - {sentToday}/{capacity || "—"} - -
- - ); } +/** "2026-08-30" → "Aug 30" for the sparkline scrub readout. */ +function formatTrendDay(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" }); +} + +// Sparkline geometry. Width matches the card's inner width (sidebar w-64 +// minus mx-2 and borders) so preserveAspectRatio="none" barely distorts +// the dots; side padding keeps markers clear of the overflow-hidden edges. +const SPARK_W = 238; +const SPARK_H = 34; +const SPARK_PAD_X = 6; +const SPARK_PAD_TOP = 6; +const SPARK_PAD_BOTTOM = 3; + /** - * Sparkline — 14 thin vertical bars, the last two weeks of send volume - * from the dashboard daily trend, normalized to the busiest day. Days - * with volume render sky; empty days stay a faint slate baseline. + * Sparkline — the last two weeks of send volume as a smooth area line + * (Catmull-Rom smoothing, gradient wash under the stroke, end-of-series + * dot with a surface ring). Full-bleed across the card; the chips row's + * top border underneath doubles as the baseline. Invisible per-day hit + * columns report the hovered day via onHover so the hero number above + * scrubs with the cursor. */ -function Sparkline({ values }: { values: number[] }) { - const bars = useMemo(() => { - const padded = - values.length >= 14 - ? values.slice(-14) - : [...Array.from({ length: 14 - values.length }, () => 0), ...values]; - const max = Math.max(...padded, 1); - return padded.map((v) => Math.round((v / max) * 100)); - }, [values]); +function Sparkline({ + points, + hovered, + onHover, +}: { + points: { date: string; sent: number }[]; + hovered: number | null; + onHover: (i: number | null) => void; +}) { + const { linePath, areaPath, dots, hasVolume } = useMemo(() => { + const n = points.length; + const baseY = SPARK_H - SPARK_PAD_BOTTOM; + if (n < 2) { + return { + linePath: "", + areaPath: "", + dots: [] as { x: number; y: number }[], + hasVolume: false, + }; + } + const max = Math.max(...points.map((p) => p.sent), 1); + const span = SPARK_W - SPARK_PAD_X * 2; + const usable = baseY - SPARK_PAD_TOP; + const pts = points.map((p, i) => ({ + x: SPARK_PAD_X + (i / (n - 1)) * span, + y: baseY - (p.sent / max) * usable, + })); + // Catmull-Rom → cubic bezier; control ys are clamped so a spike next + // to a flat run never overshoots the frame. + const clamp = (y: number) => + Math.min(baseY, Math.max(SPARK_PAD_TOP, y)); + let d = `M ${pts[0].x} ${pts[0].y}`; + for (let i = 0; i < n - 1; i++) { + const p0 = pts[i - 1] ?? pts[i]; + const p1 = pts[i]; + const p2 = pts[i + 1]; + const p3 = pts[i + 2] ?? p2; + const c1x = p1.x + (p2.x - p0.x) / 6; + const c1y = clamp(p1.y + (p2.y - p0.y) / 6); + const c2x = p2.x - (p3.x - p1.x) / 6; + const c2y = clamp(p2.y - (p3.y - p1.y) / 6); + d += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`; + } + return { + linePath: d, + areaPath: `${d} L ${pts[n - 1].x} ${baseY} L ${pts[0].x} ${baseY} Z`, + dots: pts, + hasVolume: points.some((p) => p.sent > 0), + }; + }, [points]); + + const n = points.length; + const step = n > 1 ? (SPARK_W - SPARK_PAD_X * 2) / (n - 1) : 0; + const hoverDot = hovered != null ? dots[hovered] : undefined; + const endDot = dots[dots.length - 1]; + return ( -
- {bars.map((v, i) => ( -
0 - ? "bg-sky-300 group-hover:bg-sky-400" - : "bg-slate-200 group-hover:bg-slate-300", - )} - style={{ height: `${Math.max(8, v)}%`, minHeight: "2px" }} + onHover(null)} + > + + + + + + + {linePath && hasVolume && ( + + )} + {linePath && ( + - ))} -
+ )} + {/* Hover scrub: hairline + marker on the hovered day. */} + {hoverDot && ( + <> + + + + )} + {/* End-of-series marker (today), ringed in the surface color. */} + {endDot && hovered == null && ( + + )} + {/* Invisible per-day hit columns driving the scrub. */} + {n >= 2 && + points.map((_, i) => ( + onHover(i)} + /> + ))} + ); }