reports: calls per day, missed, busiest hours, queue table
ci / check (push) Successful in 56s
ci / images (push) Successful in 35s

GET /api/reports/summary and /api/reports/queues (company-time-zone days, last 30 by
default, a year at most) and the Reports page: totals, stacked bars per day, missed per
day, a weekday x hour heatmap and the queue table. Plain divs, no chart library.

Task: M11-T6
This commit is contained in:
iamdoubz committed 2026-10-19 22:09:43 -05:00
1 parent 60846afddd
commit 29ee4ba105
13 files changed
+714 -3

No files matched your search

+2
View File
@@ -37,6 +37,7 @@ import { callRoutes } from './routes/calls.ts';
import { recordingRoutes } from './routes/recordings.ts';
import { liveCallRoutes } from './routes/livecalls.ts';
import { integrationRoutes } from './routes/integrations.ts';
import { reportRoutes } from './routes/reports.ts';
declare module 'fastify' {
interface FastifyInstance {
@@ -147,6 +148,7 @@ export function buildApp(config: Config, db: Db, publicDir = resolve('public'))
app.register(recordingRoutes);
app.register(liveCallRoutes);
app.register(integrationRoutes);
app.register(reportRoutes);
// Always registered: reply.sendFile also streams voicemail audio (with Range) from the spool.
app.register(fastifyStatic, {
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// /api/reports (04 §7.3, 05 §2 Reports): calls per day, busiest hours, per-queue stats. Admin only.
import { and, asc, sql } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import {
CALL_DIRECTIONS,
ReportRangeSchema,
type ListResponse,
type QueueReportRow,
type ReportSummary,
} from '@selfphoneng/shared';
import { requireAdmin } from '../auth/guards.ts';
import type { Db } from '../db/client.ts';
import { calls, queues } from '../db/schema.ts';
import { badRequest } from '../errors.ts';
import { todayIn } from '../generator/load.ts';
import { queueStats } from '../services/queuestats.ts';
import { getSetting } from '../services/settings.ts';
const addDays = (d: string, n: number) =>
new Date(Date.parse(`${d}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
/** Whole days in the company time zone; the last 30 days by default, a year at most. */
async function range(db: Db, query: unknown) {
const r = ReportRangeSchema.parse(query);
const tz = await getSetting(db, 'general.timezone');
const to = r.to ?? (r.from ? addDays(r.from, 29) : todayIn(tz));
const from = r.from ?? addDays(to, -29);
if (addDays(from, 366) <= to) throw badRequest('Pick at most a year');
return { tz, from, to };
}
export async function reportRoutes(app: FastifyInstance) {
const { db } = app;
app.addHook('preHandler', requireAdmin);
app.get('/api/reports/summary', async (req): Promise<ReportSummary> => {
const { tz, from, to } = await range(db, req.query);
const where = and(
sql`${calls.startedAt} >= (${from}::date)::timestamp at time zone ${tz}`,
sql`${calls.startedAt} < ((${to}::date + 1)::timestamp at time zone ${tz})`,
);
const local = sql`(${calls.startedAt} at time zone ${tz})`;
const missed = sql`${calls.status} in ('missed', 'voicemail', 'abandoned')`;
const [perDay, perHour, [t]] = await Promise.all([
db
.select({
date: sql<string>`to_char(${local}, 'YYYY-MM-DD')`,
direction: calls.direction,
n: sql<number>`count(*)::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
})
.from(calls)
.where(where)
.groupBy(sql`1`, calls.direction),
db
.select({
dow: sql<number>`extract(dow from ${local})::int`,
hour: sql<number>`extract(hour from ${local})::int`,
n: sql<number>`count(*)::int`,
})
.from(calls)
.where(where)
.groupBy(sql`1`, sql`2`),
db
.select({
calls: sql<number>`count(*)::int`,
missed: sql<number>`count(*) filter (where ${missed})::int`,
avgTalk: sql<
number | null
>`round(avg(${calls.talkSeconds}) filter (where ${calls.status} = 'answered'))::int`,
})
.from(calls)
.where(where),
]);
const days: ReportSummary['days'] = [];
for (let d = from; d <= to; d = addDays(d, 1)) {
const rows = perDay.filter((r) => r.date === d);
days.push({
date: d,
...(Object.fromEntries(
CALL_DIRECTIONS.map((dir) => [dir, rows.find((r) => r.direction === dir)?.n ?? 0]),
) as Record<(typeof CALL_DIRECTIONS)[number], number>),
missed: rows.reduce((s, r) => s + r.missed, 0),
});
}
const hours = Array.from({ length: 7 }, () => Array<number>(24).fill(0));
for (const h of perHour) hours[h.dow]![h.hour] = h.n;
return {
from,
to,
days,
totals: { calls: t!.calls, missed: t!.missed, avgTalkSeconds: t!.avgTalk },
hours,
};
});
app.get('/api/reports/queues', async (req): Promise<ListResponse<QueueReportRow>> => {
const { from, to } = await range(db, req.query);
const qs = await db.select().from(queues).orderBy(asc(queues.number));
return {
nextCursor: null,
items: await Promise.all(
qs.map(async (q) => ({
id: q.id,
number: q.number,
name: q.name,
slaSeconds: q.slaSeconds,
...(await queueStats(db, q, { from, to })),
})),
),
};
});
}
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// M11-T6: /api/reports/summary and /api/reports/queues (04 §7.3), days in the company time zone.
import type { LightMyRequestResponse } from 'fastify';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { calls, queueCalls, queues } from '../src/db/schema.ts';
import { setSetting } from '../src/services/settings.ts';
import { hasTestDb } from './db.ts';
import { ADMIN, H, createTestApp } from './app.ts';
describe.skipIf(!hasTestDb)('/api/reports', () => {
let t: Awaited<ReturnType<typeof createTestApp>>;
let get: (url: string, cookie?: string) => Promise<LightMyRequestResponse>;
let admin: string;
beforeAll(async () => {
t = await createTestApp();
await t.clearMustChange(ADMIN.email);
admin = await t.login(ADMIN.email, ADMIN.password);
get = (url, cookie = admin) => t.app.inject({ method: 'GET', url, headers: { ...H, cookie } });
await setSetting(t.db, 'general.timezone', 'America/Chicago'); // UTC-5 in May
const call = (
linkedid: string,
at: string,
direction: 'inbound' | 'outbound' | 'internal',
status: 'answered' | 'missed' | 'voicemail' | 'busy',
talkSeconds = 0,
) => ({
linkedid,
direction,
status,
startedAt: new Date(at),
endedAt: new Date(at),
talkSeconds,
});
await t.db.insert(calls).values([
call('a', '2026-05-04T14:00:00Z', 'inbound', 'answered', 60), // Mon 09:00 local
call('b', '2026-05-04T14:30:00Z', 'inbound', 'missed'), // Mon 09:30
call('c', '2026-05-04T15:00:00Z', 'outbound', 'answered', 120), // Mon 10:00
call('d', '2026-05-05T03:00:00Z', 'inbound', 'voicemail'), // still Mon, 22:00
call('e', '2026-05-06T13:00:00Z', 'internal', 'busy'), // Wed 08:00
call('f', '2026-05-09T12:00:00Z', 'inbound', 'answered', 30), // outside the range
]);
const [q] = await t.db
.insert(queues)
.values({ number: '550', name: 'Sales', timeoutDestination: { type: 'hangup' } })
.returning();
await t.db.insert(queueCalls).values([
{
queueId: q!.id,
uniqueid: '1',
linkedid: '1',
joinedAt: new Date('2026-05-04T14:00:00Z'),
outcome: 'answered',
waitSeconds: 5,
},
{
queueId: q!.id,
uniqueid: '2',
linkedid: '2',
joinedAt: new Date('2026-05-04T14:05:00Z'),
outcome: 'abandoned',
waitSeconds: 40,
},
{
queueId: q!.id,
uniqueid: '3',
linkedid: '3',
joinedAt: new Date('2026-05-09T14:05:00Z'),
outcome: 'answered',
waitSeconds: 1,
},
]);
});
afterAll(() => t?.close());
it('summary: per day by direction, missed, average talk, busiest hours', async () => {
const r = await get('/api/reports/summary?from=2026-05-04&to=2026-05-06');
expect(r.statusCode, r.body).toBe(200);
const s = r.json();
expect(s.days).toEqual([
{ date: '2026-05-04', inbound: 3, outbound: 1, internal: 0, missed: 2 },
{ date: '2026-05-05', inbound: 0, outbound: 0, internal: 0, missed: 0 },
{ date: '2026-05-06', inbound: 0, outbound: 0, internal: 1, missed: 0 },
]);
expect(s.totals).toEqual({ calls: 5, missed: 2, avgTalkSeconds: 90 });
expect(s.hours).toHaveLength(7);
expect(s.hours[1][9]).toBe(2);
expect(s.hours[1][10]).toBe(1);
expect(s.hours[1][22]).toBe(1);
expect(s.hours[3][8]).toBe(1);
expect(s.hours.flat().reduce((a: number, b: number) => a + b)).toBe(5);
});
it('queues: offered, answered, abandoned, wait and service level', async () => {
const r = await get('/api/reports/queues?from=2026-05-04&to=2026-05-06');
expect(r.json().items).toEqual([
expect.objectContaining({
number: '550',
name: 'Sales',
offered: 2,
answered: 1,
abandoned: 1,
avgWaitSeconds: 5,
withinSlaPercent: 50,
}),
]);
});
it('defaults to the last 30 days; bad ranges and non-admins are refused', async () => {
const s = (await get('/api/reports/summary')).json();
expect(s.days).toHaveLength(30);
expect(s.days.at(-1).date).toBe(s.to);
expect((await get('/api/reports/summary?from=2026-05-06&to=2026-05-04')).statusCode).toBe(400);
expect((await get('/api/reports/summary?from=2025-01-01&to=2026-05-04')).statusCode).toBe(400);
expect((await get('/api/reports/summary?from=nope')).statusCode).toBe(400);
await t.addUser('pat@example.com', 'pat-password-123');
const pat = await t.login('pat@example.com', 'pat-password-123');
expect((await get('/api/reports/queues', pat)).statusCode).toBe(403);
});
});
+12 -1
View File
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Typed fetch wrappers (05 §1). Every request sends X-Requested-With (CSRF, 04 §2.2).
import type {
QueueReportRow,
ReportRange,
ReportSummary,
ApiTokenDto,
WebhookDeliveryDto,
WebhookDto,
@@ -396,7 +399,7 @@ export const blockedApi = {
};
/** Filters as a query string (empty values left out). */
export const callQuery = (f: CallFilter) =>
export const callQuery = (f: CallFilter | ReportRange) =>
new URLSearchParams(
Object.entries(f).filter(([, v]) => v !== undefined && v !== '') as [string, string][],
).toString();
@@ -407,6 +410,14 @@ export const callsApi = {
csvUrl: (f: CallFilter) => `/api/calls/export.csv?${callQuery({ ...f, cursor: undefined })}`,
};
export const reportsApi = {
summary: (r: ReportRange) => api<ReportSummary>('GET', `/api/reports/summary?${callQuery(r)}`),
queues: (r: ReportRange) =>
api<ListResponse<QueueReportRow>>('GET', `/api/reports/queues?${callQuery(r)}`).then(
(x) => x.items,
),
};
export const recordingsApi = {
list: (cursor?: string) =>
api<ListResponse<RecordingDto>>('GET', `/api/recordings${cursor ? `?cursor=${cursor}` : ''}`),
+3
View File
@@ -26,6 +26,7 @@ import { OfficeHoursPage } from './pages/admin/OfficeHoursPage.tsx';
import { BlockedPage } from './pages/admin/BlockedPage.tsx';
import { CallsPage } from './pages/admin/CallsPage.tsx';
import { RecordingsPage } from './pages/admin/RecordingsPage.tsx';
import { ReportsPage } from './pages/admin/ReportsPage.tsx';
import { MePage } from './pages/user/MePage.tsx';
import { SetupPage } from './pages/SetupPage.tsx';
import './index.css';
@@ -75,6 +76,8 @@ const router = createBrowserRouter([
<CallsPage />
) : p.path === 'recordings' ? (
<RecordingsPage />
) : p.path === 'reports' ? (
<ReportsPage />
) : (
<ComingSoon label={p.label} milestone={p.milestone ?? ''} />
),
+1 -1
View File
@@ -26,7 +26,7 @@ export const ADMIN_PAGES = [
{ path: 'contacts', label: 'Contacts', milestone: 'M10' },
{ path: 'calls', label: 'Call history', milestone: null },
{ path: 'recordings', label: 'Recordings', milestone: null },
{ path: 'reports', label: 'Reports', milestone: 'M11' },
{ path: 'reports', label: 'Reports', milestone: null },
{ path: 'sms', label: 'SMS', milestone: 'M9' },
{ path: 'blocked', label: 'Blocked numbers', milestone: null },
{ path: 'settings', label: 'Settings', milestone: null },
+245
View File
@@ -0,0 +1,245 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Reports (05 §2): calls per day by direction, missed per day, busiest hours, queue table.
// ponytail: bars and the heatmap are plain divs and a table, no chart library.
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import type { ReportRange, ReportSummary } from '@selfphoneng/shared';
import { reportsApi } from '../../api/client.ts';
import { Alert, Button, Field, inputClass } from '../../components/ui.tsx';
import { fmtDuration } from './CallsPage.tsx';
const SERIES = [
['inbound', 'In', 'bg-sky-500'],
['outbound', 'Out', 'bg-violet-500'],
['internal', 'Internal', 'bg-zinc-400'],
] as const;
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const shortDate = (d: string) =>
new Date(`${d}T12:00:00Z`).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
function Bars({
label,
days,
segments,
}: {
label: string;
days: ReportSummary['days'];
segments: readonly (readonly [keyof ReportSummary['days'][number], string, string])[];
}) {
const total = (d: ReportSummary['days'][number]) =>
segments.reduce((s, [k]) => s + Number(d[k]), 0);
const max = Math.max(1, ...days.map(total));
return (
<figure className="flex flex-col gap-1">
<figcaption className="flex flex-wrap items-center gap-3 font-medium">
{label}
{segments.length > 1 &&
segments.map(([k, text, color]) => (
<span key={k} className="flex items-center gap-1 text-xs font-normal text-zinc-500">
<span className={`inline-block size-2.5 rounded-sm ${color}`} />
{text}
</span>
))}
<span className="text-xs font-normal text-zinc-500">most in a day: {max}</span>
</figcaption>
<div className="flex h-40 items-end gap-px border-b border-zinc-300 dark:border-zinc-700">
{days.map((d) => (
<div
key={d.date}
role="img"
aria-label={`${shortDate(d.date)}: ${segments.map(([k, text]) => `${d[k]} ${text.toLowerCase()}`).join(', ')}`}
title={`${shortDate(d.date)}: ${segments.map(([k, text]) => `${d[k]} ${text.toLowerCase()}`).join(', ')}`}
className="flex h-full min-w-0 flex-1 flex-col-reverse hover:opacity-80"
>
{segments.map(([k, , color]) => (
<div key={k} className={color} style={{ height: `${(100 * Number(d[k])) / max}%` }} />
))}
</div>
))}
</div>
<div className="flex justify-between text-xs text-zinc-500">
<span>{shortDate(days[0]!.date)}</span>
<span>{shortDate(days.at(-1)!.date)}</span>
</div>
</figure>
);
}
function Heatmap({ hours }: { hours: number[][] }) {
const max = Math.max(1, ...hours.flat());
return (
<div className="overflow-x-auto">
<table aria-label="Busiest hours" className="border-separate border-spacing-0.5 text-xs">
<thead>
<tr>
<th />
{hours[0]!.map((_, h) => (
<th key={h} scope="col" className="w-6 font-normal text-zinc-500">
{h % 3 === 0 ? h : <span className="sr-only">{h}</span>}
</th>
))}
</tr>
</thead>
<tbody>
{hours.map((row, d) => (
<tr key={d}>
<th scope="row" className="pr-1 text-left font-normal text-zinc-500">
{WEEKDAYS[d]}
</th>
{row.map((n, h) => (
<td
key={h}
title={`${WEEKDAYS[d]} ${h}:00: ${n} calls`}
className="h-6 w-6 rounded-sm bg-zinc-100 dark:bg-zinc-800"
style={
n ? { backgroundColor: `rgb(14 165 233 / ${0.15 + (0.85 * n) / max})` } : {}
}
>
<span className="sr-only">{n}</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function ReportsPage() {
const [draft, setDraft] = useState<ReportRange>({});
const [range, setRange] = useState<ReportRange>({});
const summary = useQuery({
queryKey: ['reports', 'summary', range],
queryFn: () => reportsApi.summary(range),
});
const queues = useQuery({
queryKey: ['reports', 'queues', range],
queryFn: () => reportsApi.queues(range),
});
const s = summary.data;
const date = (k: 'from' | 'to', label: string) => (
<Field label={label}>
{(p) => (
<input
{...p}
type="date"
className={inputClass}
value={draft[k] ?? ''}
onChange={(e) => setDraft((d) => ({ ...d, [k]: e.target.value || undefined }))}
/>
)}
</Field>
);
return (
<section className="flex flex-col gap-6">
<h1 className="text-xl font-semibold">Reports</h1>
<form
aria-label="Dates"
className="flex flex-wrap items-end gap-3"
onSubmit={(e) => {
e.preventDefault();
setRange(draft);
}}
>
<div className="w-40">{date('from', 'From')}</div>
<div className="w-40">{date('to', 'To')}</div>
<Button type="submit" variant="primary">
Show
</Button>
{s && (
<p className="text-sm text-zinc-500">
{shortDate(s.from)} – {shortDate(s.to)}
{!range.from && !range.to && ' (last 30 days)'}
</p>
)}
</form>
{summary.error && <Alert>{summary.error.message}</Alert>}
{s && (
<>
<dl aria-label="Totals" className="grid grid-cols-3 gap-3 text-sm sm:max-w-xl">
{(
[
['Calls', s.totals.calls],
['Missed', s.totals.missed],
[
'Average talk time',
s.totals.avgTalkSeconds === null ? '—' : fmtDuration(s.totals.avgTalkSeconds),
],
] as const
).map(([k, v]) => (
<div key={k} className="rounded-lg border border-zinc-200 p-3 dark:border-zinc-800">
<dt className="text-zinc-500">{k}</dt>
<dd className="text-lg font-semibold tabular-nums">{v}</dd>
</div>
))}
</dl>
<Bars label="Calls per day" days={s.days} segments={SERIES} />
<Bars
label="Missed per day"
days={s.days}
segments={[['missed', 'Missed', 'bg-red-500']]}
/>
<div className="flex flex-col gap-1">
<h2 className="font-medium">Busiest hours</h2>
<Heatmap hours={s.hours} />
</div>
</>
)}
<div className="flex flex-col gap-2">
<h2 className="font-medium">Queues</h2>
{queues.error && <Alert>{queues.error.message}</Alert>}
{queues.data && !queues.data.length && <p className="text-sm text-zinc-500">No queues.</p>}
{!!queues.data?.length && (
<div className="overflow-x-auto">
<table aria-label="Queue report" className="w-full text-left text-sm">
<thead className="text-zinc-500">
<tr>
{[
'Queue',
'Calls',
'Answered',
'Hung up waiting',
'Timed out',
'Average wait',
'Answered in time',
].map((h) => (
<th key={h} scope="col" className="px-2 py-1 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{queues.data.map((q) => (
<tr key={q.id} className="tabular-nums">
<td className="px-2 py-1">
<span className="font-mono">{q.number}</span> {q.name}
</td>
<td className="px-2 py-1">{q.offered}</td>
<td className="px-2 py-1">{q.answered}</td>
<td className="px-2 py-1">{q.abandoned}</td>
<td className="px-2 py-1">{q.timedOut}</td>
<td className="px-2 py-1">
{q.avgWaitSeconds === null ? '—' : `${q.avgWaitSeconds} s`}
</td>
<td className="px-2 py-1" title={`within ${q.slaSeconds} s`}>
{q.withinSlaPercent === null ? '—' : `${q.withinSlaPercent}%`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}