Skip to content

Commit f654a2f

Browse files
committed
feat(dashboard): add a Hot tickets card to the Overview
A cross-project glance at what the agent is working on (planned/spiked), what is likely next (high priority), and the queued rest. Full-width stacked lanes so an uneven split still reads as designed: an empty lane collapses to a dim header line, a populated one is a list of titles with their project, capped with "+N more". Selecting a ticket jumps into its project (#1112).
1 parent 5444bd3 commit f654a2f

4 files changed

Lines changed: 167 additions & 0 deletions

File tree

.changeset/hot-tickets-overview.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@gemstack/the-framework": minor
3+
---
4+
5+
Add a "Hot tickets" overview to the dashboard.
6+
7+
The Overview now has a cross-project glance at the tickets that matter right now, in three lanes: In progress (tickets the agent has planned or spiked), Up next (high priority, not started yet), and Queued (the rest of the open backlog). Each row names its project and jumps into it when selected.
8+
9+
It pools every project's `tickets/`, so it is a projection of the same files the agent plans from, polled so it stays live. Empty lanes collapse to a single header line, so an import-heavy repo where everything sits queued still reads as designed.

packages/framework-dashboard/components/DashboardPage.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
1010
import { usePolled } from '../lib/use-async.js'
1111
import { usePreferences } from '../lib/preferences.js'
1212
import { OnboardingChecklist } from './OnboardingChecklist.js'
13+
import { HotTickets } from './HotTickets.js'
1314
import { cn } from '../lib/utils.js'
1415
import { formatDateTime, formatRelative } from '../lib/format-date.js'
1516
import { ScrollArea } from './ui/scroll-area.js'
@@ -43,6 +44,8 @@ export function DashboardPage({
4344

4445
<NeedsYou items={interventions} onSelectProject={onSelectProject} />
4546

47+
<HotTickets onSelectProject={onSelectProject} />
48+
4649
{data === null ? (
4750
<p className="text-sm text-muted-foreground">Loading…</p>
4851
) : (
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { afterEach, describe, expect, test, vi } from 'vitest'
2+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
3+
import type { HotTicket, HotBucket } from '@gemstack/the-framework'
4+
5+
// HotTickets reads onHotTickets over the telefunc shim; stub it so the import graph stays out of
6+
// telefunc and the poll returns fixtures.
7+
const onHotTickets = vi.hoisted(() => vi.fn())
8+
vi.mock('../server/reads.telefunc.js', () => ({ onHotTickets }))
9+
10+
const { HotTickets } = await import('./HotTickets.js')
11+
12+
afterEach(cleanup)
13+
14+
const ht = (file: string, projectName: string, bucket: HotBucket, over: Record<string, unknown> = {}): HotTicket => ({
15+
projectId: projectName,
16+
projectName,
17+
bucket,
18+
ticket: { file, title: file.replace('.md', ''), summary: '', spiked: false, planned: false, ...over },
19+
})
20+
21+
describe('HotTickets (#1112)', () => {
22+
test('with no tickets it shows a hint', async () => {
23+
onHotTickets.mockResolvedValue([])
24+
render(<HotTickets onSelectProject={() => {}} />)
25+
await waitFor(() => expect(screen.getByText('No tickets yet.')).toBeTruthy())
26+
})
27+
28+
test('groups tickets into the three lanes and selecting one jumps into its project', async () => {
29+
onHotTickets.mockResolvedValue([
30+
ht('a.md', 'alpha', 'in-progress', { planned: true }),
31+
ht('b.md', 'beta', 'next', { priority: 'high' }),
32+
ht('c.md', 'alpha', 'queued'),
33+
])
34+
let picked: string | null = null
35+
render(<HotTickets onSelectProject={id => (picked = id)} />)
36+
await waitFor(() => expect(screen.getByText('a')).toBeTruthy())
37+
expect(screen.getByText('In progress')).toBeTruthy()
38+
expect(screen.getByText('Up next')).toBeTruthy()
39+
expect(screen.getByText('Queued')).toBeTruthy()
40+
fireEvent.click(screen.getByText('b'))
41+
expect(picked).toBe('beta')
42+
})
43+
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { HotTicket, HotBucket } from '@gemstack/the-framework'
2+
import { Flame } from 'lucide-react'
3+
import { onHotTickets } from '../server/reads.telefunc.js'
4+
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
5+
import { usePolled } from '../lib/use-async.js'
6+
import { cn } from '../lib/utils.js'
7+
8+
// The Overview's "hot tickets" card (#1112): a cross-project glance at what the agent is working on
9+
// (planned/spiked), what is likely next (high priority), and the queued rest. A projection of every
10+
// project's `tickets/` over the `onHotTickets` read, polled so it stays live. Selecting a ticket
11+
// jumps into its project. Advertised on the landing page, so it earns a place on the landing view.
12+
13+
const EMPTY: HotTicket[] = []
14+
15+
// The three lanes, in the order Rom listed them (#1112): worked-on, next, queued. Each carries the
16+
// dot colour that matches the rest of the status vocabulary (primary = active, warning = soon).
17+
// Stacked as full-width sections rather than columns, so an uneven split (the common case, where
18+
// most tickets sit queued) still reads as designed instead of two empty columns.
19+
const LANES: { key: HotBucket; label: string; dot: string }[] = [
20+
{ key: 'in-progress', label: 'In progress', dot: 'bg-primary' },
21+
{ key: 'next', label: 'Up next', dot: 'bg-warning' },
22+
{ key: 'queued', label: 'Queued', dot: 'bg-muted-foreground' },
23+
]
24+
25+
// A lane is capped so the card stays a glance; the rest is summarised as "+N more".
26+
const PER_LANE = 5
27+
28+
export function HotTickets({ onSelectProject }: { onSelectProject: (id: string) => void }) {
29+
const { value: tickets } = usePolled<HotTicket[]>(onHotTickets, EMPTY, 10_000, [])
30+
31+
return (
32+
<Card>
33+
<CardHeader>
34+
<CardTitle className="flex items-center gap-2">
35+
<Flame className="h-4 w-4 text-muted-foreground" />
36+
Hot tickets
37+
</CardTitle>
38+
</CardHeader>
39+
<CardContent>
40+
{tickets.length === 0 ? (
41+
<p className="py-2 text-sm text-muted-foreground">No tickets yet.</p>
42+
) : (
43+
<div className="divide-y divide-border">
44+
{LANES.map(lane => (
45+
<Lane key={lane.key} lane={lane} tickets={tickets.filter(t => t.bucket === lane.key)} onSelectProject={onSelectProject} />
46+
))}
47+
</div>
48+
)}
49+
</CardContent>
50+
</Card>
51+
)
52+
}
53+
54+
function Lane({
55+
lane,
56+
tickets,
57+
onSelectProject,
58+
}: {
59+
lane: { key: HotBucket; label: string; dot: string }
60+
tickets: HotTicket[]
61+
onSelectProject: (id: string) => void
62+
}) {
63+
const shown = tickets.slice(0, PER_LANE)
64+
const more = tickets.length - shown.length
65+
const empty = tickets.length === 0
66+
return (
67+
<div className="py-3 first:pt-0 last:pb-0">
68+
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide">
69+
{/* An empty lane dims to a single header line rather than a paragraph, so the populated
70+
lane carries the card and the zeros still say "nothing here" at a glance. */}
71+
<span aria-hidden className={cn('h-2 w-2 shrink-0 rounded-full', lane.dot, empty && 'opacity-40')} />
72+
<span className={empty ? 'text-muted-foreground' : 'text-foreground/80'}>{lane.label}</span>
73+
<span className="tabular-nums text-muted-foreground/70">{tickets.length}</span>
74+
</div>
75+
{!empty && (
76+
<ul className="mt-1.5">
77+
{shown.map(t => (
78+
<li key={`${t.projectId}:${t.ticket.file}`}>
79+
<button
80+
type="button"
81+
onClick={() => onSelectProject(t.projectId)}
82+
title={t.ticket.summary || t.ticket.title}
83+
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
84+
>
85+
<span className="min-w-0 flex-1 truncate text-sm font-medium">{t.ticket.title}</span>
86+
<TicketTag ticket={t} />
87+
<span className="shrink-0 text-xs text-muted-foreground">{t.projectName}</span>
88+
</button>
89+
</li>
90+
))}
91+
{more > 0 && (
92+
<li className="px-2 pt-0.5 text-xs text-muted-foreground">
93+
+{more} more
94+
</li>
95+
)}
96+
</ul>
97+
)}
98+
</div>
99+
)
100+
}
101+
102+
// The one fact that earns the lane: the plan/spike that made it in-progress, or the priority that
103+
// made it next. Queued rows carry nothing extra — the lane already says it.
104+
function TicketTag({ ticket: t }: { ticket: HotTicket }) {
105+
const tag = t.bucket === 'in-progress' ? (t.ticket.planned ? 'planned' : t.ticket.spiked ? 'spiked' : null) : t.bucket === 'next' ? t.ticket.priority ?? null : null
106+
if (!tag) return null
107+
return (
108+
<span className="shrink-0 rounded border border-border px-1 text-[10px] uppercase tracking-wide text-muted-foreground">
109+
{tag}
110+
</span>
111+
)
112+
}

0 commit comments

Comments
 (0)