Ticket Queue
Real-time queue interface for solvers to view and claim pending support tickets
Ticket Queue
The ticket queue is where solvers find customers waiting for help. It displays all pending support requests, ordered by urgency and wait time, allowing solvers to claim tickets and begin support sessions.
How It Works
When a customer submits a support request through the intake form, a ticket is created with status "pending" and enters the queue. Solvers see these tickets in real-time and can claim them to start a support session.
The queue implements a DoorDash-style claiming model: multiple solvers see the same tickets, but only one can successfully claim each ticket. This creates healthy competition and ensures customers get help quickly.
Urgency-Based Ordering
Tickets are sorted first by urgency level, then by creation time (oldest first within each urgency tier):
| Urgency | Priority | Description |
|---|---|---|
| Critical | Highest | Emergency, needs immediate help |
| High | High | Very urgent, blocking work |
| Medium | Normal | Somewhat urgent |
| Low | Lowest | Can wait, not blocking work |
This ordering ensures critical issues get attention first while still being fair to customers who have been waiting longer within the same urgency level.
Queue Page Components
The queue view is built from three main components that work together.
TicketCard
Each ticket displays as a card showing essential information at a glance:
export function TicketCard({
id,
customerName,
description,
deviceType,
urgency,
createdAt,
onClaim,
isClaiming,
}: TicketCardProps) {
const DeviceIcon = deviceIcons[deviceType] ?? HelpCircle;
const urgencyInfo = urgencyConfig[urgency];
const timeAgo = formatDistanceToNow(new Date(createdAt), { addSuffix: true });
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<DeviceIcon />
<div>
<h3>{customerName}</h3>
<p>{timeAgo}</p>
</div>
</div>
<Badge variant={urgencyInfo.variant}>{urgencyInfo.label}</Badge>
</CardHeader>
<CardContent>
<p className="line-clamp-2">{description}</p>
<Button onClick={() => onClaim(id)} disabled={isClaiming}>
{isClaiming ? "Claiming..." : "Claim"}
</Button>
</CardContent>
</Card>
);
}The card includes:
- Device icon - Visual indicator of what device the customer needs help with
- Customer name - Who needs help
- Time ago - How long they've been waiting (e.g., "5 minutes ago")
- Urgency badge - Color-coded urgency level
- Description - Truncated problem description
- Claim button - Action to claim the ticket
QueueList
The list component handles the grid layout and empty state:
export function QueueList({ tickets, onClaim, claimingId }: QueueListProps) {
if (tickets.length === 0) {
return (
<div className="flex flex-col items-center justify-center">
<Inbox className="h-8 w-8" />
<h3>No pending tickets</h3>
<p>New tickets will appear here when customers request help.</p>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tickets.map((ticket) => (
<TicketCard
key={ticket.id}
{...ticket}
onClaim={onClaim}
isClaiming={claimingId === ticket.id}
/>
))}
</div>
);
}QueueFilters
Solvers can filter the queue by urgency level or device type to focus on tickets matching their expertise:
export function QueueFilters({
urgencyFilter,
deviceFilter,
onFilterChange,
}: QueueFiltersProps) {
return (
<div className="flex items-center gap-3">
<Select
value={urgencyFilter ?? ""}
onValueChange={(value) => onFilterChange("urgency", value || null)}
>
<SelectTrigger>
<SelectValue placeholder="Urgency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
{/* Device filter similarly */}
</div>
);
}Claiming Tickets
The claim process uses an atomic database update to prevent race conditions. When a solver clicks "Claim":
- The UI shows a loading state on that specific ticket
- A mutation is sent to
tickets.claim - The server attempts to update the ticket status from "pending" to "claimed"
- If successful, the ticket is assigned to the solver
- If another solver already claimed it, an error is returned
claim: protectedProcedure
.input(z.object({ ticketId: z.string().uuid() }))
.mutation(async ({ ctx, input }) => {
// Atomically claim only if still pending
const result = await db
.update(tickets)
.set({
status: "claimed",
solverId: solver.id,
claimedAt: new Date(),
})
.where(
and(
eq(tickets.id, input.ticketId),
eq(tickets.status, "pending")
)
)
.returning();
if (!result[0]) {
throw new TRPCError({
code: "CONFLICT",
message: "Ticket has already been claimed by another solver",
});
}
return result[0];
}),The atomic WHERE clause eq(tickets.status, "pending") ensures that even if two solvers click "Claim" simultaneously, only one will succeed. The other receives a clear error message.
Error Handling
The queue page handles several error scenarios gracefully:
| Scenario | User Experience |
|---|---|
| Failed to load queue | Error message with "Try Again" button |
| Ticket already claimed | Inline error banner, queue refreshes |
| Network error during claim | Claim button re-enables, error shown |
| Solver not yet onboarded | Claim blocked with message |
Database Schema
Tickets are stored with the following structure:
export const tickets = pgTable("tickets", {
id: uuid("id").primaryKey().defaultRandom(),
customerInfo: jsonb("customer_info").$type<CustomerInfo>().notNull(),
description: text("description").notNull(),
deviceType: deviceTypeEnum("device_type").notNull(),
urgency: urgencyEnum("urgency").notNull().default("medium"),
status: ticketStatusEnum("status").notNull().default("pending"),
solverId: uuid("solver_id").references(() => solvers.id),
claimedAt: timestamp("claimed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});The customerInfo JSONB column stores name, phone, and optional email without requiring customer authentication, supporting the friction-free intake flow.
Future Enhancements
The queue view will be enhanced with:
- Real-time updates - New tickets appear without manual refresh (uses Ably WebSocket)
- Sound notifications - Optional audio alert when new tickets arrive
- Optimistic UI updates - Claimed tickets disappear immediately before server confirmation
Related Documentation
- API Layer - tRPC procedures for tickets
- Solver Dashboard - Main solver interface