Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
908cba5
docs: add agent team design spec — Claude Code orchestrator + OpenCod…
claude Apr 24, 2026
0d55081
docs(agent-team): v2.0 spec — address 17 production failure modes
claude Apr 24, 2026
7cd3a71
docs(agent-team): v2.1 — flake detection, forensic worktree naming, g…
claude Apr 24, 2026
7906394
feat(agent-tooling): scaffold agent team orchestration and quality gates
claude Apr 25, 2026
5e00328
feat(admin-desktop): add vitest + testing-library test infrastructure
claude Apr 25, 2026
661eb08
feat(triage): pagination + severity field — remove severityDerived, a…
claude Apr 25, 2026
1ec884d
feat(triage): j/k/Escape keyboard shortcuts for queue navigation
claude Apr 25, 2026
055134e
feat(triggers): duplicateClusterTrigger — geohash + Turf.js 200m prox…
claude Apr 25, 2026
eb8b5d6
feat(sweep): extend adminOperationsSweep with shift handoff escalatio…
claude Apr 25, 2026
71cc33c
feat(admin-desktop): ShiftHandoffModal + incoming handoff banner for A.3
claude Apr 25, 2026
9e10af1
fix(lint): resolve lint errors in merge-duplicates and shift-handoff …
claude Apr 25, 2026
58fbb93
feat(callables): add test files and index exports for merge-duplicate…
claude Apr 25, 2026
86a7b4a
docs: update progress with Phase 5 Cluster A completion
claude Apr 25, 2026
c0e85aa
security(merge-duplicates): add auth checks, rate limiting, transacti…
claude Apr 25, 2026
e21409a
security(shift-handoff): add auth checks, rate limiting, transaction,…
claude Apr 25, 2026
9afbaff
fix(triggers): add error handling and resilience
claude Apr 25, 2026
c9195ce
fix(admin-desktop): error handling, type safety, UX improvements
claude Apr 25, 2026
cc82ec1
infra(firestore): add composite index for shift_handoffs sweep queries
claude Apr 25, 2026
83cc91f
test(callables): add security and edge case coverage
claude Apr 25, 2026
7be2d19
fix(merge-duplicates): schema uniqueness + atomic preconditions
claude Apr 25, 2026
e010d41
fix(shift-handoff): Timestamp types, snapshot completeness, atomic id…
claude Apr 25, 2026
15e3e4a
fix(triggers): validation guards, query ordering, conditional updates
claude Apr 25, 2026
44764f1
fix(admin-desktop): CodeRabbit review fixes
claude Apr 25, 2026
32c6e72
test(callables): use valid UUIDs for idempotency keys
claude Apr 25, 2026
c8e6592
infra(firestore): add report_ops createdAt DESC index
claude Apr 25, 2026
a55fc35
fix: CodeRabbit review round 3 — atomicity, validation, UX sync
claude Apr 25, 2026
eb0e69f
fix: CodeRabbit review round 4 — type alignment, validation, tests, i…
claude Apr 25, 2026
f502012
fix: CodeRabbit review round 5 — idempotency race guard, cluster vali…
claude Apr 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .claude/escalations/.gitkeep
Empty file.
12 changes: 12 additions & 0 deletions .lint-baselines.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"@bantayog/shared-types": 0,
"@bantayog/citizen-pwa": 0,
"@bantayog/shared-sms-parser": 0,
"@bantayog/responder-app": 0,
"@bantayog/shared-ui": 0,
"@bantayog/functions": 0,
"@bantayog/admin-desktop": 0,
"@bantayog/shared-validators": 0,
"@bantayog/shared-data": 0,
"@bantayog/e2e-tests": 0
}
4 changes: 3 additions & 1 deletion apps/admin-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint src",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@bantayog/shared-types": "workspace:*",
Expand All @@ -21,6 +22,7 @@
"devDependencies": {
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
Expand Down
80 changes: 80 additions & 0 deletions apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

const mockInitiateHandoff = vi.hoisted(() => vi.fn())

vi.mock('../app/firebase', () => ({ db: {} }))

vi.mock('@bantayog/shared-ui', () => ({
useAuth: () => ({
claims: { municipalityId: 'daet', role: 'municipal_admin' },
signOut: vi.fn(),
}),
}))

vi.mock('../services/callables', () => ({
callables: {
verifyReport: vi.fn(),
rejectReport: vi.fn(),
initiateShiftHandoff: mockInitiateHandoff,
acceptShiftHandoff: vi.fn(),
},
}))

vi.mock('../hooks/useMuniReports', () => ({
useMuniReports: () => ({
reports: [],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
}),
}))

vi.mock('../hooks/usePendingHandoffs', () => ({
usePendingHandoffs: () => [],
}))

vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () => <div>detail</div> }))
vi.mock('../pages/DispatchModal', () => ({ DispatchModal: () => <div>dispatch</div> }))
vi.mock('../pages/CloseReportModal', () => ({ CloseReportModal: () => <div>close</div> }))

Comment on lines +7 to +42
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Mock scaffolding is over the smell budget for a unit test.

This setup is quite mock-heavy and will be brittle as TriageQueuePage evolves. Consider moving this flow to an emulator-backed integration test and keeping only a minimal unit smoke test here.
As per coding guidelines: “Mocks are a smell budget. If a unit test needs more than ~20 lines of mock setup, consider integration test with emulators instead.”

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/__tests__/shift-handoff-modal.test.tsx` around lines 7
- 42, The test currently over-mocks the TriageQueuePage flow (many vi.mock
blocks including useMuniReports, usePendingHandoffs, callables ->
mockInitiateHandoff and others), making it brittle; split this into two changes:
(1) convert this heavy test into an emulator-backed integration test that
exercises TriageQueuePage end-to-end (remove the vi.mock scaffolding for
useMuniReports, usePendingHandoffs and callables and instead run against the
Firebase emulator and real hooks), and (2) replace the original file with a
minimal unit smoke test that only keeps essential lightweight mocks (e.g.,
useAuth) and asserts TriageQueuePage renders without error; ensure unique
references like mockInitiateHandoff, useMuniReports, usePendingHandoffs, and
TriageQueuePage are removed from the unit test and instead validated in the new
integration test.

import { TriageQueuePage } from '../pages/TriageQueuePage'

describe('ShiftHandoffModal', () => {
beforeEach(() => {
mockInitiateHandoff.mockResolvedValue({ success: true, handoffId: 'h-new-1' })
})

it('renders Start Handoff button in header', () => {
render(<TriageQueuePage />)
expect(screen.getByRole('button', { name: /start handoff/i })).toBeInTheDocument()
})

it('opens ShiftHandoffModal on Start Handoff click', async () => {
const user = userEvent.setup()
render(<TriageQueuePage />)
await user.click(screen.getByRole('button', { name: /start handoff/i }))
expect(screen.getByRole('dialog', { name: /shift handoff/i })).toBeInTheDocument()
})

it('calls initiateShiftHandoff on Initiate click', async () => {
const user = userEvent.setup()
render(<TriageQueuePage />)
await user.click(screen.getByRole('button', { name: /start handoff/i }))
const notesField = screen.getByLabelText(/notes/i)
await user.type(notesField, 'End of day shift')
await user.click(screen.getByRole('button', { name: /initiate/i }))
expect(mockInitiateHandoff).toHaveBeenCalledWith(
expect.objectContaining({ notes: 'End of day shift' }),
)
})
})

describe('Incoming handoff banner', () => {
it('shows no banner when no pending handoffs', () => {
render(<TriageQueuePage />)
expect(screen.queryByRole('button', { name: /accept handoff/i })).not.toBeInTheDocument()
})
})
188 changes: 188 additions & 0 deletions apps/admin-desktop/src/__tests__/triage-queue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

const mockUseMuniReports = vi.fn()

vi.mock('../hooks/useMuniReports', () => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
useMuniReports: (...args: unknown[]) => mockUseMuniReports(...args),
}))

vi.mock('../app/firebase', () => ({
db: {},
}))

vi.mock('@bantayog/shared-ui', () => ({
useAuth: () => ({
claims: { municipalityId: 'daet', role: 'municipal_admin' },
signOut: vi.fn(),
}),
}))

vi.mock('../services/callables', () => ({
callables: {
verifyReport: vi.fn(),
rejectReport: vi.fn(),
},
}))

vi.mock('../hooks/usePendingHandoffs', () => ({
usePendingHandoffs: () => [],
}))

vi.mock('../pages/ReportDetailPanel', () => ({
ReportDetailPanel: () => <div>detail</div>,
}))
vi.mock('../pages/DispatchModal', () => ({
DispatchModal: () => <div>dispatch</div>,
}))
vi.mock('../pages/CloseReportModal', () => ({
CloseReportModal: () => <div>close</div>,
}))

import { TriageQueuePage } from '../pages/TriageQueuePage'

describe('TriageQueuePage', () => {
beforeEach(() => {
mockUseMuniReports.mockReturnValue({
reports: [],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
})
})

it('renders Load More button when hasMore is true', () => {
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
],
hasMore: true,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument()
})

it('does not render Load More button when hasMore is false', () => {
render(<TriageQueuePage />)
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument()
})

it('calls loadMore when Load More is clicked', () => {
const loadMore = vi.fn()
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
],
hasMore: true,
loadMore,
loading: false,
error: null,
})
render(<TriageQueuePage />)
fireEvent.click(screen.getByRole('button', { name: /load more/i }))
expect(loadMore).toHaveBeenCalledTimes(1)
})

it('shows Showing X count', () => {
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
{
reportId: 'r2',
status: 'new',
severity: 'medium',
createdAt: null,
municipalityLabel: '',
},
],
hasMore: true,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
expect(screen.getByText(/showing 2/i)).toBeInTheDocument()
})

it('renders severity from severity field, not severityDerived', () => {
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
expect(screen.getByText(/high/i)).toBeInTheDocument()
})

it('pressing j selects the next report in the list', async () => {
const user = userEvent.setup()
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
{
reportId: 'r2',
status: 'new',
severity: 'medium',
createdAt: null,
municipalityLabel: '',
},
],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
await user.keyboard('j')
expect(screen.getByText('detail')).toBeInTheDocument()
})

it('pressing k moves selection backward', async () => {
const user = userEvent.setup()
mockUseMuniReports.mockReturnValue({
reports: [
{ reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
{
reportId: 'r2',
status: 'new',
severity: 'medium',
createdAt: null,
municipalityLabel: '',
},
],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
await user.keyboard('jj')
await user.keyboard('k')
expect(screen.getByText('detail')).toBeInTheDocument()
})
Comment on lines +150 to +172
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Test assertion does not verify backward movement.

The test presses jj then k, but the assertion only checks that the detail panel exists. This doesn't verify that k actually moved selection backward (from index 1 to index 0). The same assertion would pass if k had no effect.

Consider asserting on a distinguishing attribute of the selected report (e.g., via aria-selected, a CSS class, or checking which report's details are shown if ReportDetailPanel receives reportId).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/__tests__/triage-queue.test.tsx` around lines 150 -
172, The test currently only checks that the detail panel rendered but doesn't
assert that pressing 'k' moved the selection from the second item back to the
first; update the test for TriageQueuePage to assert a distinguishing marker of
the selected report after the key sequence (for example check that the list item
for reportId 'r1' has aria-selected="true" or a selected CSS class, or assert
that ReportDetailPanel shows reportId 'r1' instead of 'r2'); locate the test
using mockUseMuniReports and the rendered TriageQueuePage, perform the same
user.keyboard('jj') then user.keyboard('k') steps, then replace the existing
expect(screen.getByText('detail')).toBeInTheDocument() with an assertion that
verifies the selected report changed back to r1 (using aria-selected, className,
or visible reportId in the detail panel).


it('keyboard shortcuts do not fire when a modal is open', async () => {
const user = userEvent.setup()
mockUseMuniReports.mockReturnValue({
reports: [],
hasMore: false,
loadMore: vi.fn(),
loading: false,
error: null,
})
render(<TriageQueuePage />)
await user.keyboard('j')
await user.keyboard('k')
expect(screen.queryByText('detail')).not.toBeInTheDocument()
})
Comment on lines +174 to +187
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test does not verify modal-blocking behavior.

This test uses an empty reports array, so pressing j/k would never select anything regardless of modal state. The test passes trivially without actually verifying that keyboard shortcuts are blocked when a modal is open.

Consider testing with a non-empty reports array and setting dispatchForReportId or closeForReportId to a truthy value to properly verify the modalOpen guard in the useEffect.

💡 Suggested fix
 it('keyboard shortcuts do not fire when a modal is open', async () => {
   const user = userEvent.setup()
+  // Need a way to trigger modal state - consider exposing a prop or 
+  // clicking a button that sets dispatchForReportId/closeForReportId
   mockUseMuniReports.mockReturnValue({
-    reports: [],
+    reports: [
+      { reportId: 'r1', status: 'new', severity: 'high', createdAt: null, municipalityLabel: '' },
+    ],
     hasMore: false,
     loadMore: vi.fn(),
     loading: false,
     error: null,
   })
   render(<TriageQueuePage />)
+  // First select a report, then open a modal, then verify j/k is blocked
+  await user.keyboard('j')
+  expect(screen.getByText('detail')).toBeInTheDocument()
+  // Trigger modal open (e.g., via dispatch button click)
+  // Then verify subsequent j/k does not change selection
-  await user.keyboard('j')
-  await user.keyboard('k')
-  expect(screen.queryByText('detail')).not.toBeInTheDocument()
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/admin-desktop/src/__tests__/triage-queue.test.tsx` around lines 174 -
187, The test "keyboard shortcuts do not fire when a modal is open" currently
uses an empty reports array so key handlers never select anything; update the
mock returned by mockUseMuniReports to include at least one report object and
set either dispatchForReportId or closeForReportId to a truthy value so the
component's modalOpen guard in the useEffect is exercised, then simulate the
same key presses (user.keyboard('j')/('k')) and assert that the detail view is
not shown; ensure you reference the mocked hook mockUseMuniReports and the
TriageQueuePage render so the modalOpen branch runs.

})
Loading
Loading