Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { act, cleanup, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import { OnboardingEmailSendStatus } from "./onboarding-email-send-status"

afterEach(() => {
cleanup()
vi.useRealTimers()
})

describe("OnboardingEmailSendStatus", () => {
it("updates the visual timer while announcing only meaningful phase changes", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-08-27T00:00:00Z"))
const startedAt = Date.now()

render(<OnboardingEmailSendStatus startedAt={startedAt} />)

const liveStatus = screen.getByRole("status")
const initialAnnouncement = liveStatus.textContent
expect(screen.getByText(/0s elapsed/)).toHaveAttribute("aria-hidden", "true")

act(() => vi.advanceTimersByTime(1000))

expect(screen.getByText(/1s elapsed/)).toHaveAttribute("aria-hidden", "true")
expect(liveStatus).toHaveTextContent(initialAnnouncement || "")

act(() => vi.advanceTimersByTime(9000))

expect(screen.getByText(/10s elapsed/)).toHaveAttribute("aria-hidden", "true")
expect(liveStatus).toHaveTextContent("Onboarding email is still sending")

act(() => vi.advanceTimersByTime(15_000))

expect(screen.getByText(/25s elapsed/)).toHaveAttribute("aria-hidden", "true")
expect(liveStatus).toHaveTextContent("Onboarding email is taking longer than expected")
})

it("preserves the elapsed warning when remounted with the parent start time", () => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-08-27T00:00:00Z"))
const startedAt = Date.now()
const firstRender = render(<OnboardingEmailSendStatus startedAt={startedAt} />)

act(() => vi.advanceTimersByTime(26_000))
firstRender.unmount()
render(<OnboardingEmailSendStatus startedAt={startedAt} />)

expect(screen.getByText(/26s elapsed/)).toHaveAttribute("aria-hidden", "true")
expect(screen.getByRole("status")).toHaveTextContent(
"Onboarding email is taking longer than expected",
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react"

function elapsedSecondsSince(startedAt: number, now = Date.now()) {
return Math.max(0, Math.floor((now - startedAt) / 1000))
}

function visualSendProgress(seconds: number) {
if (seconds < 10) {
return `Sending — ${seconds}s elapsed. Checking details and waiting for the mail server.`
}
if (seconds < 25) {
return `Still sending — ${seconds}s elapsed. Waiting for the mail server to confirm receipt.`
}
return `Taking longer than expected — ${seconds}s elapsed. The request is still active; do not retry yet, as that could send a duplicate email.`
}

function announcedSendPhase(seconds: number) {
if (seconds < 10) {
return "Sending onboarding email. Checking details and waiting for the mail server."
}
if (seconds < 25) {
return "Onboarding email is still sending. Waiting for the mail server to confirm receipt."
}
return "Onboarding email is taking longer than expected. The request is still active; do not retry yet, as that could send a duplicate email."
}

export function OnboardingEmailSendStatus({ startedAt }: { startedAt: number }) {
const [now, setNow] = useState(Date.now)
const elapsedSeconds = elapsedSecondsSince(startedAt, now)

useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(interval)
}, [])

return (
<>
<span className="text-sm text-muted-foreground" aria-hidden="true">
{visualSendProgress(elapsedSeconds)}
</span>
<span className="sr-only" role="status">
{announcedSendPhase(elapsedSeconds)}
</span>
</>
)
}
16 changes: 16 additions & 0 deletions apps/admin_dashboard/src/dashboard-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
jobLeadClassificationMethodLabel,
labelForOnboardingState,
linkedinUrl,
messageForApiError,
onboardingStateValue,
toneForOnboardingState,
} from "./dashboard-utils"
Expand Down Expand Up @@ -70,4 +71,19 @@ describe("dashboard utility helpers", () => {
expect(jobLeadClassificationMethodLabel("unknown")).toBe("Unknown")
expect(jobLeadClassificationMethodLabel()).toBe("Unknown")
})

it("turns malformed onboarding email requests into actionable messages", () => {
expect(messageForApiError({ error: "empty_email_body" }, "Request failed")).toBe(
"The email body is empty. Add content before sending.",
)
expect(messageForApiError({ error: "invalid_payload" }, "Request failed")).toBe(
"The request contains invalid information. Refresh the page and try again.",
)
})

it("keeps shared onboarding eligibility errors action-neutral", () => {
expect(messageForApiError({ error: "contact_not_onboarding_eligible" }, "Request failed")).toBe(
"This candidate is no longer eligible for onboarding. Refresh the queue and review their status.",
)
})
})
59 changes: 59 additions & 0 deletions apps/admin_dashboard/src/dashboard-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,65 @@ export function jsonPreview(value: unknown) {
return JSON.stringify(value, null, 2)
}

export function messageForApiError(record: Record<string, unknown>, fallback: string) {
const detail = record.detail
if (typeof detail === "string" && detail.trim()) return detail

const error = record.error
if (typeof error !== "string") return fallback
if (error === "person_not_found") {
const person =
typeof record.person === "string" && record.person.trim() ? record.person : "that person"
return `No CRM person, ERPNext user, or ERPNext supplier matched "${person}". Try an email address or an exact name from CRM/ERPNext.`
}
if (error === "candidate_not_found") {
return "The selected person record is no longer available. Search again and choose one of the current matches."
}
if (error === "invalid_crm_profile") {
return "Paste a valid CRM Contact profile URL or Contact id."
}
if (error === "crm_profile_not_found") {
return "That CRM Contact profile was not found."
}
if (error === "crm_profile_mismatch") {
return "CRM returned a different Contact than the profile requested. Check the profile URL and try again."
}
if (error === "crm_profile_lookup_failed") {
return "CRM profile lookup failed. Try again after CRM is reachable."
}
if (error === "crm_lookup_failed") {
return "Could not verify the candidate in CRM. No email was sent; try again once CRM is reachable."
}
if (error === "contact_not_onboarding_eligible") {
return "This candidate is no longer eligible for onboarding. Refresh the queue and review their status."
}
if (error === "candidate_terminal_onboarding_state") {
return "This candidate is already in a terminal onboarding state, so no email was sent."
}
if (error === "recipient_email_required") {
return "The candidate does not have a valid email address, so no email was sent."
}
if (error === "reply_to_email_required") {
return "Your Reply-To email is unavailable, so no email was sent."
}
if (error === "smtp_not_configured") {
return "Onboarding email SMTP is not configured, so no email was sent."
}
if (error === "email_send_failed") {
return "The mail server could not confirm it accepted the email. It was not marked sent; check the recipient inbox or SMTP logs before retrying to avoid a duplicate."
}
if (error === "empty_email_body") {
return "The email body is empty. Add content before sending."
}
if (error === "invalid_payload") {
return "The request contains invalid information. Refresh the page and try again."
}
if (error === "ambiguous_person") {
return "Multiple people matched. Choose the matching person record."
}
return error || fallback
}

export function isTerminalJobStatus(value?: string | null) {
return ["succeeded", "dead", "canceled"].includes(
String(value || "")
Expand Down
57 changes: 25 additions & 32 deletions apps/admin_dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { type ReactNode, StrictMode, useEffect, useMemo, useRef, useState } from "react"
import { createRoot } from "react-dom/client"
import { Empty } from "@/components/empty"
import { OnboardingEmailSendStatus } from "@/components/onboarding-email-send-status"
import { SortableTableHead } from "@/components/sortable-table-head"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
Expand All @@ -48,6 +49,7 @@ import {
jsonPreview,
labelForOnboardingState,
linkedinUrl,
messageForApiError,
onboardingStateValue,
type Tone,
toneForOnboardingState,
Expand Down Expand Up @@ -674,38 +676,6 @@ function stringFieldFromPayload(payload: unknown, key: string) {
return JSON.stringify(value)
}

function messageForApiError(record: Record<string, unknown>, fallback: string) {
const detail = record.detail
if (typeof detail === "string" && detail.trim()) return detail

const error = record.error
if (typeof error !== "string") return fallback
if (error === "person_not_found") {
const person =
typeof record.person === "string" && record.person.trim() ? record.person : "that person"
return `No CRM person, ERPNext user, or ERPNext supplier matched "${person}". Try an email address or an exact name from CRM/ERPNext.`
}
if (error === "candidate_not_found") {
return "The selected person record is no longer available. Search again and choose one of the current matches."
}
if (error === "invalid_crm_profile") {
return "Paste a valid CRM Contact profile URL or Contact id."
}
if (error === "crm_profile_not_found") {
return "That CRM Contact profile was not found."
}
if (error === "crm_profile_mismatch") {
return "CRM returned a different Contact than the profile requested. Check the profile URL and try again."
}
if (error === "crm_profile_lookup_failed") {
return "CRM profile lookup failed. Try again after CRM is reachable."
}
if (error === "ambiguous_person") {
return "Multiple people matched. Choose the matching person record."
}
return error || fallback
}

function messageFromUnknown(error: unknown, fallback: string) {
if (typeof error === "string" && error.trim()) return error
if (error instanceof Error && error.message.trim()) return error.message
Expand Down Expand Up @@ -984,6 +954,9 @@ function App() {
} | null>(null)
const [jobDetail, setJobDetail] = useState<JobDetail | null>(null)
const [loading, setLoading] = useState<Record<string, boolean>>({})
const [onboardingEmailSendStartedAt, setOnboardingEmailSendStartedAt] = useState<
Record<string, number>
>({})
const [devErrors, setDevErrors] = useState<DashboardDevError[]>([])
const [historicalPersonChoice, setHistoricalPersonChoice] = useState<{
projectId: string
Expand Down Expand Up @@ -2229,6 +2202,10 @@ function App() {
return null
}
const key = `onboarding-email-send:${contactId}`
setOnboardingEmailSendStartedAt((current) => ({
...current,
[contactId]: current[contactId] ?? Date.now(),
}))
setBusy(key, true)
try {
const payload = await requestJson<OnboardingEmailDraft>(
Expand Down Expand Up @@ -2263,6 +2240,11 @@ function App() {
return null
} finally {
setBusy(key, false)
setOnboardingEmailSendStartedAt((current) => {
const next = { ...current }
delete next[contactId]
return next
})
}
}

Expand Down Expand Up @@ -3138,6 +3120,7 @@ function App() {
people={sortedOnboarding}
sort={sort.onboarding}
loading={loading}
emailSendStartedAt={onboardingEmailSendStartedAt}
onboardingQuery={onboardingQuery}
onboardingState={onboardingState}
onboarderFilter={onboarderFilter}
Expand Down Expand Up @@ -7433,6 +7416,7 @@ function OnboardingView(props: {
people: Person[]
sort: { key: string; direction: SortDirection }
loading: Record<string, boolean>
emailSendStartedAt: Record<string, number>
canWrite: boolean
onboardingQuery: string
onboardingState: string
Expand Down Expand Up @@ -7655,6 +7639,7 @@ function OnboardingView(props: {
}
person={person}
loading={props.loading}
sendStartedAt={props.emailSendStartedAt[person.crm_contact_id || ""]}
canWrite={props.canWrite}
onAssign={props.onAssign}
onSuggestions={props.onSuggestions}
Expand Down Expand Up @@ -8025,6 +8010,7 @@ function EngineerSetupPanel({
function OnboardingRow({
person,
loading,
sendStartedAt,
canWrite,
onAssign,
onSuggestions,
Expand All @@ -8038,6 +8024,7 @@ function OnboardingRow({
}: {
person: Person
loading: Record<string, boolean>
sendStartedAt?: number
canWrite: boolean
onAssign: (contactId: string | undefined, onboarder: string) => void
onSuggestions: (contactId: string | undefined) => Promise<OnboardingVolunteer[]>
Expand Down Expand Up @@ -8290,6 +8277,9 @@ function OnboardingRow({
{emailDraft ? "Edit draft" : "Draft email"}
</Button>
) : null}
{sendBusy && sendStartedAt !== undefined && (!emailOpen || !emailDraft) ? (
<OnboardingEmailSendStatus startedAt={sendStartedAt} />
) : null}
</div>
</TableCell>
<TableCell>
Expand Down Expand Up @@ -8443,6 +8433,9 @@ function OnboardingRow({
<Send />
{sendBusy ? "Sending" : "Send"}
</Button>
{sendBusy && sendStartedAt !== undefined ? (
<OnboardingEmailSendStatus startedAt={sendStartedAt} />
) : null}
{sendUnavailableMessage ? (
<span className="text-sm text-muted-foreground">
{sendUnavailableMessage}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"index.html": {
"file": "assets/index-Cw__n5P5.js",
"file": "assets/index-CVqOV_1R.js",
"name": "index",
"src": "index.html",
"isEntry": true,
"css": [
"assets/index-CqyoXZwA.css"
"assets/index-CVSoNxSm.css"
]
}
}

Large diffs are not rendered by default.

Large diffs are not rendered by default.

This file was deleted.

This file was deleted.

4 changes: 2 additions & 2 deletions apps/api/src/five08/backend/static/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>508 Operations Dashboard</title>
<script type="module" crossorigin src="/dashboard/assets/index-Cw__n5P5.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-CqyoXZwA.css">
<script type="module" crossorigin src="/dashboard/assets/index-CVqOV_1R.js"></script>
<link rel="stylesheet" crossorigin href="/dashboard/assets/index-CVSoNxSm.css">
</head>
<body>
<div id="root"></div>
Expand Down