All files / src/components/today work-queue.ts

100% Statements 28/28
100% Branches 32/32
100% Functions 9/9
100% Lines 28/28

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295                                                                                                                                                            13x                                                                                 11x                         9x 9x 3x   6x                                             53x 15x     1x       9x 9x                           53x       1x   9x                         53x                   13x                                                             54x   54x 8x 8x 4x                       4x   4x 2x                         54x 8x 8x                             54x    
/**
 * Today's work queue — the data layer. Plan §1.3.
 *
 * WHAT THIS REPLACES
 * The old dashboard printed four figures, an empty list and a 0% checklist of
 * six chores. A brand-new designer saw `0 · 0 · 0 · 0` under an amber "pending
 * review" banner whose only advice was "Contact admin for more details." None
 * of that answers the one question a working day asks: **who is waiting?**
 *
 * THE CONSTRAINT, AND WHY IT SHAPED THE BANDS
 * Every row below is built from a query the portal ALREADY makes. No new
 * endpoint, no new aggregate, no N+1 fan-out. Where the mockup asks for
 * something no existing query can answer, the row is DROPPED rather than
 * faked — see "UNREAD, and why it is not here" below.
 *
 * ONE DEFINITION OF "OVERDUE"
 * This module does not decide what overdue means. It imports
 * `isDueAtOverdue` from `components/crm/lead-overdue.ts`, which is the one
 * definition in the portal and the one `/leads` renders from — the same QUERY
 * (`useKanbanData`) and now literally the same PREDICATE, not a second copy
 * of the sign test that happens to agree today. A pro who clears Today and
 * still sees red on Leads stops trusting both screens, and two copies is how
 * that happens.
 *
 * `LeadCard` still carries its own inline copy of the sign test, in
 * `formatNextReminder`. That one is reported rather than fixed here — it is a
 * file this group does not own — but it is the last copy left.
 *
 * UNREAD, AND WHY IT IS NOT HERE
 * The mockup's third row is "Meera S replied · UNREAD". The portal has no
 * query that can answer it. Homeowner replies live per-lead behind
 * `crmApi.getLeadMessages(proId, leadId)`, and the pro's unread count is
 * cleared server-side the moment a thread is opened
 * (`apps/api/src/routes/pro/crm/inquiry-messages.routes.ts`). There is no
 * aggregate endpoint, and asking for one message page per lead is exactly the
 * fan-out the "no new data" constraint forbids. The band therefore ships with
 * two codes, not three. Adding `GET /api/pro/:proId/crm/unread` would restore
 * the row and nothing else here would change.
 *
 * NO NEW DATE VOCABULARY
 * Every relative age comes from `lib/format-date.ts` — the portal's one place
 * for that — so "2d ago" means the same thing on Today as it does everywhere
 * else. This module adds the word "due" in front of it and nothing more.
 *
 * STATUS CODES
 * A status is a printed code, never a pill, and the ink comes from the shared
 * registry (`components/ui/status-ink.ts`) through `StatusBadge`. `WORK_TONE`
 * below exists only because three of the words this screen prints — `overdue`,
 * `building`, `live` — are not yet in `status-badge.tsx`'s word lists, so the
 * registry resolves them to the neutral tone. The map is the call site's one
 * table rather than six scattered `variant=` props; it should be deleted the
 * day those words join the registry. See the note in the group's handoff.
 *
 * Index red appears NOWHERE in here. It is not a state ink; the band marker
 * and the current row's 3px inset bar are the only two places it is printed,
 * and both are in `WorkQueue.tsx`.
 */
 
import type {
	KanbanData,
	NotificationItem,
	SocialDraft,
	WebsiteBuildJob,
} from "../../lib/api";
import { formatRelativeDate } from "../../lib/format-date";
import { isDueAtOverdue } from "../crm/lead-overdue";
import type { StatusTone } from "../ui/status-ink";
 
/** `useKanbanData` returns leads enriched with `nextReminderDueAt`. */
type KanbanLead = KanbanData["leads"][number];
 
/**
 * The printed codes this screen uses, and the registry tone each resolves to.
 *
 * `ready` and `draft` already resolve correctly through
 * `status-badge.tsx`'s own word lists; they are listed anyway so the whole
 * vocabulary of the screen is readable in one place.
 */
export const WORK_TONE = {
	Overdue: "error",
	New: "info",
	Ready: "success",
	Building: "info",
	Draft: "warning",
	Live: "success",
	Archived: "default",
} as const satisfies Record<string, StatusTone>;
 
export type WorkCode = keyof typeof WORK_TONE;
 
/** One row of a work-queue band. */
export interface WorkItem {
	/** Stable React key. */
	id: string;
	/** The printed code, e.g. `Overdue`. */
	code: WorkCode;
	/** The entity: "Call Priya Menon". */
	title: string;
	/** Its context: "3BHK Gachibowli". Omitted when there is none to print. */
	meta?: string;
	/** The relative age, from `lib/format-date.ts`. */
	when: string;
	/** Where the one action goes. */
	href: string;
	/** The action's printed label, e.g. `Log`. */
	actionLabel: string;
	/** The row's accessible name — the whole row is one link. */
	actionDescription: string;
}
 
/**
 * Is this notification an untouched enquiry?
 *
 * `new_inquiry` is the event the marketplace emits when a homeowner submits
 * an enquiry (`routes/marketplace/inquiries.routes.ts`), and `readAt === null`
 * means the pro has not opened it. Both halves come from the notification row
 * itself, so "new" is the server's word, not a guess re-derived here.
 */
function isNewEnquiry(notification: NotificationItem): boolean {
	return (
		notification.eventType === "new_inquiry" && notification.readAt === null
	);
}
 
/**
 * Where a `new_inquiry` notification points.
 *
 * The API writes `data.leadId` alongside a legacy `data.url` of
 * `/crm/leads/:id`. The legacy path still redirects, but there is no reason to
 * send a click through a redirect when the id is right there.
 */
function enquiryHref(notification: NotificationItem): string {
	const leadId = notification.data?.leadId;
	if (typeof leadId === "number" || typeof leadId === "string") {
		return `/leads/${leadId}`;
	}
	return "/leads";
}
 
export interface WaitingOnYouInput {
	leads: readonly KanbanLead[];
	notifications: readonly NotificationItem[];
	now?: number;
}
 
/**
 * ▌ WAITING ON YOU — work nobody else can move.
 *
 * Ordered the way the mockup prints it, and for the same reason: the longest
 * wait is the most expensive one.
 *   1. overdue follow-ups, most overdue first;
 *   2. new enquiries, newest first — an 18-minute-old enquiry is still warm
 *      and is the one row here where speed, not age, is the point.
 */
export function buildWaitingOnYou({
	leads,
	notifications,
	now = Date.now(),
}: WaitingOnYouInput): WorkItem[] {
	const overdue = leads
		.filter((lead) => isDueAtOverdue(lead.nextReminderDueAt, now))
		.sort(
			(a, b) =>
				new Date(a.nextReminderDueAt as string).getTime() -
				new Date(b.nextReminderDueAt as string).getTime(),
		)
		.map<WorkItem>((lead) => {
			const context = lead.requirement || lead.projectType || lead.location;
			return {
				id: `lead-${lead.id}`,
				code: "Overdue",
				title: `Call ${lead.customerName}`,
				meta: context ?? undefined,
				when: `due ${formatRelativeDate(lead.nextReminderDueAt, {
					granularity: "time",
				})}`,
				href: `/leads/${lead.id}`,
				actionLabel: "Log",
				actionDescription: `Log contact with ${lead.customerName}`,
			};
		});
 
	const enquiries = notifications
		.filter(isNewEnquiry)
		.sort(
			(a, b) =>
				new Date(b.dateCreated).getTime() - new Date(a.dateCreated).getTime(),
		)
		.map<WorkItem>((notification) => ({
			id: `enquiry-${notification.id}`,
			code: "New",
			title: notification.title,
			meta: notification.body ?? undefined,
			when: formatRelativeDate(notification.dateCreated, {
				granularity: "time",
			}),
			href: enquiryHref(notification),
			actionLabel: "Open",
			actionDescription: `Open ${notification.title}`,
		}));
 
	return [...overdue, ...enquiries];
}
 
/**
 * The build statuses that mean a website deploy is still in flight.
 *
 * `deployed`, `failed` and `cancelled` are finished — a finished build is not
 * something anyone is waiting on, and a FAILED one is the website page's
 * problem to report, with its logs, not a row here with no way to act on it.
 */
const BUILD_IN_FLIGHT: ReadonlySet<WebsiteBuildJob["status"]> = new Set([
	"pending",
	"queued",
	"building",
	"deploying",
]);
 
export interface WaitingOnSomeoneElseInput {
	drafts: readonly SocialDraft[];
	latestBuild: WebsiteBuildJob | null | undefined;
}
 
/**
 * ▌ WAITING ON SOMEONE ELSE — work in flight elsewhere.
 *
 * A reel the pro can now download, a reel still rendering, and a website
 * build still running. All three come from queries the portal already makes
 * (`useSocialDrafts`, `useWebsiteSettings`).
 *
 * The mockup's third row is a blog "generating · step 3 of 5". `blogs` has no
 * generation-progress field — `BlogStatus` is
 * `draft | pending_approval | approved | published | archived`, a review
 * lifecycle, not a pipeline — so there is no step to print and the row is
 * dropped. A blog awaiting the pro's approval is waiting on the PRO, not on
 * someone else, and belongs in the other band the day an approval query is
 * wired up.
 */
export function buildWaitingOnSomeoneElse({
	drafts,
	latestBuild,
}: WaitingOnSomeoneElseInput): WorkItem[] {
	const items: WorkItem[] = [];
 
	for (const draft of drafts) {
		const name = draft.projectTitle ?? "Untitled project";
		if (draft.status === "ready") {
			items.push({
				id: `draft-${draft.id}`,
				code: "Ready",
				title: `Reel "${name}"`,
				meta: "ready to download",
				when: draft.expiresAt
					? `expires ${formatRelativeDate(draft.expiresAt)}`
					: "",
				href: `/studio/${draft.id}`,
				actionLabel: "Open",
				actionDescription: `Open the ${name} reel`,
			});
			continue;
		}
		if (draft.status === "pending" || draft.status === "processing") {
			items.push({
				id: `draft-${draft.id}`,
				code: "Building",
				title: `Reel "${name}"`,
				meta: "rendering",
				when: formatRelativeDate(draft.dateCreated, { granularity: "time" }),
				href: `/studio/${draft.id}`,
				actionLabel: "Open",
				actionDescription: `Open the ${name} reel`,
			});
		}
	}
 
	if (latestBuild && BUILD_IN_FLIGHT.has(latestBuild.status)) {
		const started = latestBuild.startedAt ?? latestBuild.dateCreated;
		items.push({
			id: `build-${latestBuild.id}`,
			code: "Building",
			title: "Website build running",
			when: started
				? `started ${formatRelativeDate(String(started), {
						granularity: "time",
					})}`
				: "",
			href: "/website",
			actionLabel: "Open",
			actionDescription: "Open the website build",
		});
	}
 
	return items;
}