Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6471065
feat(prc1): maintain hasFcmToken flag on token registration and inval…
claude Apr 25, 2026
125ca22
test(prc1): cover hasFcmToken clearing on all-invalid and preservatio…
claude Apr 25, 2026
d839b30
feat(prc2): add followUpConsent to report_sms_consent materialization
claude Apr 25, 2026
7d2c7b0
feat(prc3): expand massAlertRequestDoc status enum; add rules for ndr…
claude Apr 25, 2026
b9001b2
feat(c1): add mass_alert SMS template + renderBroadcastTemplate + enq…
claude Apr 25, 2026
717ec99
feat(c1): sendMassAlertFcm — batched FCM multicast with 500-token bat…
claude Apr 25, 2026
36bc947
feat(c1): massAlertReachPlanPreview + sendMassAlert + escalation + fo…
claude Apr 25, 2026
31a8a39
fix(c1): use .count().get() aggregate in massAlertReachPlanPreviewCore
claude Apr 25, 2026
2860f0a
fix(c1): add canActOnScope to escalation, strengthen FCM test asserti…
claude Apr 25, 2026
d76cf42
feat(c1): MassAlertModal — reach preview, direct send, NDRRMC escalat…
claude Apr 25, 2026
a3ce484
feat(shared-data): add CAMARINES_NORTE_MUNICIPALITY_IDS for analytics…
claude Apr 25, 2026
67382da
feat(c2): analyticsSnapshotWriter — daily Firestore count() aggregate…
claude Apr 25, 2026
002895a
feat(c2): AnalyticsDashboardPage — live count + 7-day SVG trend chart…
claude Apr 25, 2026
663da6e
chore: Cluster C + PRE-C verification gate — all tests pass, lint+typ…
claude Apr 25, 2026
1e183f7
docs: update progress + learnings for Phase 5 Cluster C completion
claude Apr 25, 2026
7bd9301
fix(ci): add `as any` cast to vi.spyOn where mocks in mass-alert test…
claude Apr 25, 2026
71d6b46
fix(backend): address PR #66 review comments on mass-alert, fcm, sms
claude Apr 25, 2026
b46b5fb
fix(frontend): address PR #66 review comments on admin-desktop
claude Apr 25, 2026
30cbb5d
test: add coverage for PR #66 review fixes
claude Apr 25, 2026
946abda
fix(rules+templates): address PR #66 security and encoding comments
claude Apr 25, 2026
23e7a3d
docs: fix typo in learnings.md per PR #66 nitpick
claude Apr 25, 2026
ca3acd2
fix(rules+templates): address PR #66 security and encoding comments
claude Apr 25, 2026
3b7ccae
fix: address PR #66 code review comments
claude Apr 26, 2026
1dfb206
fix: address PR #66 second round review comments
claude Apr 26, 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
6 changes: 4 additions & 2 deletions apps/admin-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"dependencies": {
"@bantayog/shared-types": "workspace:*",
"@bantayog/shared-ui": "workspace:*",
"@bantayog/shared-validators": "workspace:*",
"@tanstack/react-query": "^5.99.2",
"firebase": "^12.12.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
Expand All @@ -27,7 +29,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"happy-dom": "^15.11.0",
"vitest": "^4.1.4",
"vite": "^8.0.8"
"vite": "^8.0.8",
"vitest": "^4.1.4"
}
}
106 changes: 106 additions & 0 deletions apps/admin-desktop/src/__tests__/analytics-dashboard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

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

const { mockGetCountFromServer, mockGetDocs, mockGetDoc, mockDoc, mockWhere } = vi.hoisted(() => ({
mockGetCountFromServer: vi.fn(),
mockGetDocs: vi.fn(),
mockGetDoc: vi.fn(),
mockDoc: vi.fn(() => ({})),
mockWhere: vi.fn(() => ({})),
}))

vi.mock('firebase/firestore', () => ({
getFirestore: vi.fn(() => ({})),
getCountFromServer: mockGetCountFromServer,
collection: vi.fn(() => ({})),
query: vi.fn(() => ({})),
where: mockWhere,
orderBy: vi.fn(() => ({})),
limit: vi.fn(() => ({})),
getDocs: mockGetDocs,
getDoc: mockGetDoc,
doc: mockDoc,
}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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

function wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}

describe('AnalyticsDashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetCountFromServer.mockResolvedValue({ data: () => ({ count: 42 }) })
mockGetDocs.mockResolvedValue({ docs: [] })
mockGetDoc.mockResolvedValue({ exists: () => false })
mockWhere.mockReturnValue({})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('renders the live active-incidents count', async () => {
render(<AnalyticsDashboardPage />, { wrapper })
expect(await screen.findByText('42')).toBeInTheDocument()
})

it('shows a loading state while analytics count is fetching', () => {
mockGetCountFromServer.mockImplementationOnce(
() =>
new Promise<void>(() => {
/* never resolves */
}),
)
render(<AnalyticsDashboardPage />, { wrapper })
expect(screen.getByText(/loading/i)).toBeInTheDocument()
})

it('shows trend loading state while snapshot data is fetching', async () => {
mockGetDocs.mockImplementationOnce(
() =>
new Promise(() => {
/* never resolves */
}),
)
render(<AnalyticsDashboardPage />, { wrapper })
expect(await screen.findByText('Loading trend…')).toBeInTheDocument()
})

it("scopes data to the caller's municipalityId for muni admins", async () => {
render(<AnalyticsDashboardPage />, { wrapper })
expect(await screen.findByText(/daet/i)).toBeInTheDocument()
// Verify the live-count query included the municipalityId filter.
const hasMuniFilter = (mockWhere.mock.calls as unknown[][]).some(
(args) => args[0] === 'municipalityId' && args[1] === '==' && args[2] === 'daet',
)
expect(hasMuniFilter).toBe(true)
})

it('renders a trend chart when snapshots are present', async () => {
mockGetDocs.mockResolvedValueOnce({
docs: [
{
id: '2026-04-20',
data: () => ({
reportsByStatus: { verified: 5, closed: 2 },
}),
},
],
})
mockGetDoc.mockResolvedValueOnce({
exists: () => true,
data: () => ({ reportsByStatus: { verified: 5, closed: 2 } }),
})
render(<AnalyticsDashboardPage />, { wrapper })
expect(await screen.findByLabelText('7-day trend chart')).toBeInTheDocument()
expect(screen.getByLabelText(/2026-04-20: 7 reports/)).toBeInTheDocument()
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
143 changes: 143 additions & 0 deletions apps/admin-desktop/src/__tests__/mass-alert-modal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

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

const mockPreview = vi.hoisted(() => vi.fn())
const mockSend = vi.hoisted(() => vi.fn())
const mockEscalate = vi.hoisted(() => vi.fn())

vi.mock('../services/callables', () => ({
callables: {
massAlertReachPlanPreview: mockPreview,
sendMassAlert: mockSend,
requestMassAlertEscalation: mockEscalate,
},
}))

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

const DIRECT_PLAN = {
route: 'direct',
fcmCount: 200,
smsCount: 150,
segmentCount: 1,
unicodeWarning: false,
}
const NDRRMC_PLAN = {
route: 'ndrrmc_escalation',
fcmCount: 6000,
smsCount: 2000,
segmentCount: 1,
unicodeWarning: false,
}

describe('MassAlertModal', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPreview.mockResolvedValue(DIRECT_PLAN)
mockSend.mockResolvedValue({ requestId: 'req-1' })
mockEscalate.mockResolvedValue({ requestId: 'req-2' })
})

it('shows GSM-7 indicator and correct segment count for ASCII message', async () => {
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'ALERT: Typhoon warning')
expect(screen.getByText(/GSM-7/i)).toBeInTheDocument()
})

it('shows UCS-2 warning when message contains unicode characters', async () => {
const user = userEvent.setup()
mockPreview.mockResolvedValue({ ...DIRECT_PLAN, unicodeWarning: true })
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Alerto sa ñ lugar')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
expect(await screen.findByText(/⚠ UCS-2 \(multi-byte\)/i)).toBeInTheDocument()
})

it('shows Preview Reach button', () => {
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
expect(screen.getByRole('button', { name: /preview reach/i })).toBeInTheDocument()
})

it('shows fcmCount and smsCount after preview loads', async () => {
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test alert')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
expect(await screen.findByText(/200/)).toBeInTheDocument()
expect(screen.getByText(/150/)).toBeInTheDocument()
})

it('shows Direct Send badge when route is direct', async () => {
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
expect(await screen.findByText(/direct/i)).toBeInTheDocument()
})

it('shows NDRRMC Escalation badge when route is ndrrmc_escalation', async () => {
mockPreview.mockResolvedValue(NDRRMC_PLAN)
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
expect(await screen.findByText(/NDRRMC escalation required/i)).toBeInTheDocument()
})

it('disables Send button when route is ndrrmc_escalation', async () => {
mockPreview.mockResolvedValue(NDRRMC_PLAN)
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
await screen.findByText(/NDRRMC escalation required/i)
expect(screen.getByRole('button', { name: /^send alert$/i })).toBeDisabled()
})

it('shows Request NDRRMC Escalation button when route is ndrrmc_escalation', async () => {
mockPreview.mockResolvedValue(NDRRMC_PLAN)
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
expect(
await screen.findByRole('button', { name: /request ndrrmc escalation/i }),
).toBeInTheDocument()
})

it('calls sendMassAlert on Send click (direct path)', async () => {
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test alert')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
await screen.findByText(/200/)
await user.click(screen.getByRole('button', { name: /^send alert$/i }))
expect(mockSend).toHaveBeenCalledTimes(1)
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Test alert',
targetScope: expect.objectContaining({ municipalityIds: ['daet'] }),
}),
)
})

it('calls requestMassAlertEscalation on escalation CTA click', async () => {
mockPreview.mockResolvedValue(NDRRMC_PLAN)
const user = userEvent.setup()
render(<MassAlertModal municipalityId="daet" onClose={vi.fn()} />)
await user.type(screen.getByLabelText(/message/i), 'Test')
await user.click(screen.getByRole('button', { name: /preview reach/i }))
await user.click(await screen.findByRole('button', { name: /request ndrrmc escalation/i }))
expect(mockEscalate).toHaveBeenCalledTimes(1)
expect(mockEscalate).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Test',
targetScope: expect.objectContaining({ municipalityIds: ['daet'] }),
}),
)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ vi.mock('../hooks/useMuniReports', () => ({
}))

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

vi.mock('../pages/ReportDetailPanel', () => ({ ReportDetailPanel: () => <div>detail</div> }))
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-desktop/src/__tests__/triage-queue.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock('../services/callables', () => ({
}))

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

vi.mock('../pages/ReportDetailPanel', () => ({
Expand Down
5 changes: 2 additions & 3 deletions apps/admin-desktop/src/hooks/useMuniReports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from 'firebase/firestore'
import { db } from '../app/firebase'
import type { ReportStatus, Severity } from '@bantayog/shared-types'
import { ACTIVE_REPORT_STATUSES } from '@bantayog/shared-types'

export interface MuniReportRow {
reportId: string
Expand All @@ -22,8 +23,6 @@ export interface MuniReportRow {
municipalityLabel: string
}

const ACTIVE_STATUSES: ReportStatus[] = ['new', 'awaiting_verify', 'verified', 'assigned']

export function useMuniReports(municipalityId: string | undefined) {
const [limitCount, setLimitCount] = useState(100)
const [reports, setReports] = useState<MuniReportRow[]>([])
Expand All @@ -48,7 +47,7 @@ export function useMuniReports(municipalityId: string | undefined) {
const q = query(
collection(db, 'report_ops'),
where('municipalityId', '==', municipalityId),
where('status', 'in', ACTIVE_STATUSES),
where('status', 'in', ACTIVE_REPORT_STATUSES),
orderBy('createdAt', 'desc'),
limit(limitCount + 1),
)
Expand Down
Loading
Loading