SavvySolve Docs

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):

UrgencyPriorityDescription
CriticalHighestEmergency, needs immediate help
HighHighVery urgent, blocking work
MediumNormalSomewhat urgent
LowLowestCan 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:

components/queue/TicketCard.tsx
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:

components/queue/QueueList.tsx
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:

components/queue/QueueFilters.tsx
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":

  1. The UI shows a loading state on that specific ticket
  2. A mutation is sent to tickets.claim
  3. The server attempts to update the ticket status from "pending" to "claimed"
  4. If successful, the ticket is assigned to the solver
  5. If another solver already claimed it, an error is returned
server/routers/tickets.ts
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:

ScenarioUser Experience
Failed to load queueError message with "Try Again" button
Ticket already claimedInline error banner, queue refreshes
Network error during claimClaim button re-enables, error shown
Solver not yet onboardedClaim blocked with message

Database Schema

Tickets are stored with the following structure:

lib/db/schema/tickets.ts
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

On this page