Skip to content

Commit 4f3bbf6

Browse files
committed
feat: add Meetings Still Polling detail table with sidebar to Diavgeia admin
Extract getBackoffState() helper from inline tier calculation logic so it can be reused. Expand getPollingStats() to return per-meeting details (unlinked subjects, backoff state, poll history) instead of just a count. Add a collapsible table showing each still-polling meeting with city, date, unlinked/eligible counts, first/last poll dates (with timestamp tooltips on hover), backoff tier, and a details sidebar listing the unlinked subject names with a link to the meeting admin page. Refactor getPollingHistoryForMeeting() to use the shared helper.
1 parent ad7c618 commit 4f3bbf6

File tree

4 files changed

+373
-39
lines changed

4 files changed

+373
-39
lines changed

src/components/admin/diavgeia/PollingStats.tsx

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,23 @@ interface RecentPoll {
6060
responseBody: string | null;
6161
}
6262

63+
interface StillPollingMeeting {
64+
cityId: string;
65+
meetingId: string;
66+
meetingDate: string;
67+
unlinkedSubjects: Array<{ id: string; name: string }>;
68+
totalEligibleSubjects: number;
69+
totalPolls: number;
70+
firstPollAt: string | null;
71+
lastPollAt: string | null;
72+
currentTierLabel: string | null;
73+
nextPollEligible: string | null;
74+
}
75+
6376
interface PollingStatsData {
6477
backoffSchedule: BackoffTier[];
6578
maxPollingDays: number;
79+
meetingsStillPolling: StillPollingMeeting[];
6680
summary: {
6781
totalDiscoveries: number;
6882
meetingsStillPolling: number;
@@ -206,6 +220,7 @@ export function PollingStats({ stats, pollCities, cityFilter, pollMeetings, meet
206220
const [sortField, setSortField] = useState<SortField>('discoveredAt');
207221
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
208222
const [selectedPoll, setSelectedPoll] = useState<RecentPoll | null>(null);
223+
const [selectedMeeting, setSelectedMeeting] = useState<StillPollingMeeting | null>(null);
209224
const { updateParam, updateParams, isPending } = useUrlParams();
210225

211226
const handleSort = (field: SortField) => {
@@ -243,7 +258,7 @@ export function PollingStats({ stats, pollCities, cityFilter, pollMeetings, meet
243258
},
244259
{
245260
title: 'Meetings Still Polling',
246-
value: stats.summary.meetingsStillPolling,
261+
value: stats.meetingsStillPolling.length,
247262
icon: <Activity className="h-4 w-4" />,
248263
description: 'With unlinked subjects',
249264
},
@@ -276,11 +291,90 @@ export function PollingStats({ stats, pollCities, cityFilter, pollMeetings, meet
276291
);
277292

278293
return (
294+
<>
279295
<Sheet open={!!selectedPoll} onOpenChange={(open) => !open && setSelectedPoll(null)}>
280296
<div className="space-y-6">
281297
{/* Summary Cards */}
282298
<StatsCard items={summaryItems} columns={4} />
283299

300+
{/* Meetings Still Polling */}
301+
<CollapsibleSection
302+
title="Meetings Still Polling"
303+
badge={`${stats.meetingsStillPolling.length} meetings`}
304+
defaultOpen={stats.meetingsStillPolling.length > 0}
305+
>
306+
{stats.meetingsStillPolling.length === 0 ? (
307+
<div className="p-8 text-center text-muted-foreground">
308+
No meetings are currently being polled.
309+
</div>
310+
) : (
311+
<div className="overflow-x-auto">
312+
<table className="w-full text-sm">
313+
<thead>
314+
<tr className="bg-muted/50 border-b text-muted-foreground">
315+
<th className="text-left px-4 py-2 font-medium">City</th>
316+
<th className="text-left px-4 py-2 font-medium">Meeting ID</th>
317+
<th className="text-left px-4 py-2 font-medium">Meeting Date</th>
318+
<th className="text-left px-4 py-2 font-medium">Unlinked</th>
319+
<th className="text-left px-4 py-2 font-medium">Polls</th>
320+
<th className="text-left px-4 py-2 font-medium">First Poll</th>
321+
<th className="text-left px-4 py-2 font-medium">Last Poll</th>
322+
<th className="text-left px-4 py-2 font-medium">Backoff</th>
323+
<th className="text-right px-4 py-2 font-medium">Details</th>
324+
</tr>
325+
</thead>
326+
<TooltipProvider>
327+
<tbody>
328+
{stats.meetingsStillPolling.map(m => (
329+
<tr key={`${m.cityId}:${m.meetingId}`} className="border-b last:border-b-0 hover:bg-muted/30">
330+
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">{m.cityId}</td>
331+
<td className="px-4 py-2 whitespace-nowrap font-mono text-xs">{m.meetingId}</td>
332+
<td className="px-4 py-2 whitespace-nowrap">{m.meetingDate}</td>
333+
<td className="px-4 py-2 whitespace-nowrap">
334+
{m.unlinkedSubjects.length} / {m.totalEligibleSubjects}
335+
</td>
336+
<td className="px-4 py-2 whitespace-nowrap text-center">{m.totalPolls}</td>
337+
<td className="px-4 py-2 whitespace-nowrap">
338+
{m.firstPollAt ? (
339+
<Tooltip>
340+
<TooltipTrigger asChild>
341+
<span className="block cursor-default">{new Date(m.firstPollAt).toLocaleDateString()}</span>
342+
</TooltipTrigger>
343+
<TooltipContent side="bottom">{new Date(m.firstPollAt).toLocaleString()}</TooltipContent>
344+
</Tooltip>
345+
) : 'Never'}
346+
</td>
347+
<td className="px-4 py-2 whitespace-nowrap">
348+
{m.lastPollAt ? (
349+
<Tooltip>
350+
<TooltipTrigger asChild>
351+
<span className="block cursor-default">{new Date(m.lastPollAt).toLocaleDateString()}</span>
352+
</TooltipTrigger>
353+
<TooltipContent side="bottom">{new Date(m.lastPollAt).toLocaleString()}</TooltipContent>
354+
</Tooltip>
355+
) : 'Never'}
356+
</td>
357+
<td className="px-4 py-2 whitespace-nowrap text-xs">
358+
{m.currentTierLabel ?? '\u2014'}
359+
</td>
360+
<td className="px-4 py-2 text-right">
361+
<Button
362+
variant="ghost"
363+
size="sm"
364+
onClick={() => setSelectedMeeting(m)}
365+
>
366+
<Eye className="h-4 w-4" />
367+
</Button>
368+
</td>
369+
</tr>
370+
))}
371+
</tbody>
372+
</TooltipProvider>
373+
</table>
374+
</div>
375+
)}
376+
</CollapsibleSection>
377+
284378
{/* Recent Polls */}
285379
<CollapsibleSection
286380
title="Recent Polls"
@@ -579,5 +673,82 @@ export function PollingStats({ stats, pollCities, cityFilter, pollMeetings, meet
579673
</SheetContent>
580674
)}
581675
</Sheet>
676+
677+
{/* Still Polling Meeting Details Sidebar */}
678+
<Sheet open={!!selectedMeeting} onOpenChange={(open) => !open && setSelectedMeeting(null)}>
679+
{selectedMeeting && (
680+
<SheetContent className="sm:max-w-lg overflow-y-auto">
681+
<SheetHeader>
682+
<SheetTitle>Meeting Polling Details</SheetTitle>
683+
<SheetDescription>
684+
{selectedMeeting.cityId} / {selectedMeeting.meetingId}
685+
</SheetDescription>
686+
</SheetHeader>
687+
688+
<div className="mt-6 space-y-6">
689+
{/* Metadata Grid */}
690+
<div className="grid grid-cols-2 gap-4">
691+
<div>
692+
<div className="text-sm text-muted-foreground mb-1">City</div>
693+
<div className="text-sm font-mono">{selectedMeeting.cityId}</div>
694+
</div>
695+
<div>
696+
<div className="text-sm text-muted-foreground mb-1">Meeting Date</div>
697+
<div className="text-sm font-medium">{selectedMeeting.meetingDate}</div>
698+
</div>
699+
<div>
700+
<div className="text-sm text-muted-foreground mb-1">Backoff Tier</div>
701+
<div className="text-sm font-medium">{selectedMeeting.currentTierLabel ?? 'Not started'}</div>
702+
</div>
703+
<div>
704+
<div className="text-sm text-muted-foreground mb-1">Next Poll Eligible</div>
705+
<div className="text-sm font-medium">
706+
{selectedMeeting.nextPollEligible
707+
? new Date(selectedMeeting.nextPollEligible).toLocaleString()
708+
: 'Next cron run'}
709+
</div>
710+
</div>
711+
<div>
712+
<div className="text-sm text-muted-foreground mb-1">Total Polls</div>
713+
<div className="text-sm font-medium">{selectedMeeting.totalPolls}</div>
714+
</div>
715+
<div>
716+
<div className="text-sm text-muted-foreground mb-1">Last Poll</div>
717+
<div className="text-sm font-medium">
718+
{selectedMeeting.lastPollAt ? new Date(selectedMeeting.lastPollAt).toLocaleString() : 'Never'}
719+
</div>
720+
</div>
721+
</div>
722+
723+
{/* Meeting Admin Link */}
724+
<Link href={`/${selectedMeeting.cityId}/${selectedMeeting.meetingId}/admin`}>
725+
<Button variant="outline" className="w-full">
726+
<ExternalLink className="h-4 w-4 mr-2" />
727+
Go to Meeting Admin
728+
</Button>
729+
</Link>
730+
731+
{/* Unlinked Subjects */}
732+
<div>
733+
<div className="text-sm font-medium mb-2">
734+
Unlinked Subjects ({selectedMeeting.unlinkedSubjects.length} / {selectedMeeting.totalEligibleSubjects})
735+
</div>
736+
{selectedMeeting.unlinkedSubjects.length === 0 ? (
737+
<p className="text-sm text-muted-foreground">All subjects have linked decisions.</p>
738+
) : (
739+
<ul className="space-y-2">
740+
{selectedMeeting.unlinkedSubjects.map(s => (
741+
<li key={s.id} className="text-sm border rounded-md px-3 py-2 bg-muted/20">
742+
{s.name}
743+
</li>
744+
))}
745+
</ul>
746+
)}
747+
</div>
748+
</div>
749+
</SheetContent>
750+
)}
751+
</Sheet>
752+
</>
582753
);
583754
}

src/lib/tasks/__tests__/pollDecisions.test.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { shouldSkipPolling, BACKOFF_SCHEDULE, MAX_POLLING_DAYS } from '../pollDecisionsBackoff';
1+
import { shouldSkipPolling, getBackoffState, BACKOFF_SCHEDULE, MAX_POLLING_DAYS } from '../pollDecisionsBackoff';
22

33
// Helper: create a Date that is `daysAgo` days before now
44
const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000);
@@ -116,3 +116,82 @@ describe('shouldSkipPolling', () => {
116116
});
117117
});
118118
});
119+
120+
describe('getBackoffState', () => {
121+
describe('no history', () => {
122+
it('returns nulls when both dates are null', () => {
123+
expect(getBackoffState(null, null)).toEqual({
124+
currentTierLabel: null,
125+
nextPollEligible: null,
126+
});
127+
});
128+
129+
it('returns nulls when firstPollAt is null', () => {
130+
expect(getBackoffState(null, daysAgo(1))).toEqual({
131+
currentTierLabel: null,
132+
nextPollEligible: null,
133+
});
134+
});
135+
136+
it('returns nulls when lastPollAt is null', () => {
137+
expect(getBackoffState(daysAgo(1), null)).toEqual({
138+
currentTierLabel: null,
139+
nextPollEligible: null,
140+
});
141+
});
142+
});
143+
144+
describe('week 1 (days 0-7): every cron run', () => {
145+
it('returns "Every cron run" with no next poll restriction', () => {
146+
const result = getBackoffState(daysAgo(3), daysAgo(0));
147+
expect(result.currentTierLabel).toBe('Every cron run');
148+
expect(result.nextPollEligible).toBeNull();
149+
});
150+
});
151+
152+
describe('week 2 (days 7-14)', () => {
153+
it('returns week 2 tier label', () => {
154+
const result = getBackoffState(daysAgo(8), daysAgo(0));
155+
expect(result.currentTierLabel).toBe('Week 2: every 2d');
156+
});
157+
158+
it('returns future next poll eligible when recently polled', () => {
159+
const result = getBackoffState(daysAgo(8), daysAgo(0));
160+
expect(result.nextPollEligible).not.toBeNull();
161+
expect(new Date(result.nextPollEligible!).getTime()).toBeGreaterThan(Date.now());
162+
});
163+
164+
it('returns null next poll eligible when interval has passed', () => {
165+
const result = getBackoffState(daysAgo(8), daysAgo(3));
166+
expect(result.nextPollEligible).toBeNull();
167+
});
168+
});
169+
170+
describe('week 3 (days 14-21)', () => {
171+
it('returns week 3 tier label', () => {
172+
const result = getBackoffState(daysAgo(15), daysAgo(0));
173+
expect(result.currentTierLabel).toBe('Week 3: every 3d');
174+
});
175+
});
176+
177+
describe('week 4+ (days 21+)', () => {
178+
it('returns week 4 tier label', () => {
179+
const result = getBackoffState(daysAgo(25), daysAgo(0));
180+
expect(result.currentTierLabel).toBe('Week 4: every 7d');
181+
});
182+
});
183+
184+
describe('max polling days exceeded', () => {
185+
it(`returns stopped label after ${MAX_POLLING_DAYS} days`, () => {
186+
const result = getBackoffState(daysAgo(MAX_POLLING_DAYS + 1), daysAgo(8));
187+
expect(result.currentTierLabel).toContain('Stopped');
188+
expect(result.currentTierLabel).toContain(`${MAX_POLLING_DAYS}`);
189+
expect(result.nextPollEligible).toBeNull();
190+
});
191+
192+
it(`returns active tier just before ${MAX_POLLING_DAYS} days`, () => {
193+
const result = getBackoffState(daysAgo(MAX_POLLING_DAYS - 1), daysAgo(8));
194+
expect(result.currentTierLabel).not.toContain('Stopped');
195+
});
196+
});
197+
});

0 commit comments

Comments
 (0)