Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/functional/user-overview-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ flowchart TB
- Completed total
- Within due date
- Beyond due date
- The summary panel and donut chart use the same active filter scope as the completed table, including the optional `User` filter and the completed date range.
- Donut chart for within vs beyond due date.

### 3) Completed tasks by date
Expand Down Expand Up @@ -96,7 +97,7 @@ flowchart TB
- CSV export is available for all tables.
- The user filter is optional; if not selected, results span all users.
- User Overview excludes records where `role_category_label` is Judicial (case-insensitive), so Judicial role category data is not shown in tables, charts, summaries, or role-category filter options on this page.
- Completed total is facts-backed from `analytics.snapshot_user_completed_facts` (`SUM(tasks)` within the active filters).
- Completed total and completed summary are facts-backed from `analytics.snapshot_user_completed_facts` (`SUM(tasks)` and `SUM(within_due)` within the active filters).
- Completed tasks by task name remains row-level from `analytics.snapshot_task_rows` to preserve interval-based average calculations.
- AJAX section refreshes only load the requested section's data path (for example, completed-by-date data is fetched only for the completed-by-date section).
- Sorting state and pagination are preserved through hidden form inputs.
Expand Down
9 changes: 5 additions & 4 deletions docs/technical/data-sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ Note:
- Outstanding dashboard open-task aggregate sections do not read from this table in the warm path; they are facts-backed via `snapshot_task_daily_facts`.

### analytics.snapshot_user_completed_facts
Used for `/users` completed-by-date aggregated chart/table data and facts-backed completed total counting.
Used for `/users` completed summary totals, completed-by-date aggregated chart/table data, and facts-backed completed total counting.

Required columns:
- snapshot_id
Expand Down Expand Up @@ -265,12 +265,13 @@ For `/users` "Completed tasks by task name", averages are calculated from `analy

Both formulas include rows with null intervals in the denominator (`COUNT(*)`) while treating null interval values as zero in the summed numerator.

### User Overview completed totals
For `/users` completed total, SQL sums `snapshot_user_completed_facts.tasks` within the selected filter scope:
### User Overview completed totals and summary
For `/users` completed total and completed summary, SQL sums `snapshot_user_completed_facts` within the selected filter scope:

- `SELECT COALESCE(SUM(tasks), 0)::int AS total`
- `SELECT COALESCE(SUM(within_due), 0)::int AS within`

This facts-backed count preserves User Overview filters (including optional assignee and completed date range filters) while avoiding row-level completed-count scans on `snapshot_task_rows`.
These facts-backed aggregates preserve User Overview filters (including optional assignee and completed date range filters) while avoiding row-level completed-count scans on `snapshot_task_rows`. The generic `/completed` dashboard summary still reads from `analytics.snapshot_task_daily_facts` because that page does not expose an assignee filter.

### Created-event determination
Created events in task daily facts are determined by `created_date IS NOT NULL` (case state does not gate inclusion). For `date_role = 'created'`, `task_status` is derived as:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ type OverviewFilterOptionsParams = {

type OverviewFilterOptionsInput = OverviewFilterOptionsParams | AnalyticsQueryOptions | undefined;

function buildUserOverviewCompletedFactsWhere(
snapshotId: number,
filters: AnalyticsFilters,
queryOptions?: AnalyticsQueryOptions
): Prisma.Sql {
const conditions: Prisma.Sql[] = [asOfSnapshotCondition(snapshotId)];
if (filters.completedFrom) {
conditions.push(Prisma.sql`completed_date >= ${filters.completedFrom}`);
}
if (filters.completedTo) {
conditions.push(Prisma.sql`completed_date <= ${filters.completedTo}`);
}
if (filters.user && filters.user.length > 0) {
conditions.push(Prisma.sql`assignee IN (${Prisma.join(filters.user)})`);
}
return buildAnalyticsWhere(filters, conditions, queryOptions);
}

export class TaskFactsRepository {
private resolveOverviewFilterOptionsParams(params: OverviewFilterOptionsInput): OverviewFilterOptionsParams {
if (!params) {
Expand Down Expand Up @@ -522,22 +540,28 @@ export class TaskFactsRepository {
`);
}

async fetchUserOverviewCompletedSummaryRows(
snapshotId: number,
filters: AnalyticsFilters,
queryOptions?: AnalyticsQueryOptions
): Promise<CompletedSummaryRow[]> {
const whereClause = buildUserOverviewCompletedFactsWhere(snapshotId, filters, queryOptions);

return tmPrisma.$queryRaw<CompletedSummaryRow[]>(Prisma.sql`
SELECT
COALESCE(SUM(tasks), 0)::int AS total,
COALESCE(SUM(within_due), 0)::int AS within
FROM analytics.snapshot_user_completed_facts
${whereClause}
`);
}

async fetchUserOverviewCompletedTaskCount(
snapshotId: number,
filters: AnalyticsFilters,
queryOptions?: AnalyticsQueryOptions
): Promise<number> {
const conditions: Prisma.Sql[] = [asOfSnapshotCondition(snapshotId)];
if (filters.completedFrom) {
conditions.push(Prisma.sql`completed_date >= ${filters.completedFrom}`);
}
if (filters.completedTo) {
conditions.push(Prisma.sql`completed_date <= ${filters.completedTo}`);
}
if (filters.user && filters.user.length > 0) {
conditions.push(Prisma.sql`assignee IN (${Prisma.join(filters.user)})`);
}
const whereClause = buildAnalyticsWhere(filters, conditions, queryOptions);
const whereClause = buildUserOverviewCompletedFactsWhere(snapshotId, filters, queryOptions);

const rows = await tmPrisma.$queryRaw<{ total: number }[]>(Prisma.sql`
SELECT COALESCE(SUM(tasks), 0)::int AS total
Expand Down
23 changes: 10 additions & 13 deletions src/main/modules/analytics/userOverview/page.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { completedComplianceSummaryService } from '../completed/visuals/completedComplianceSummaryService';
import { emptyOverviewFilterOptions } from '../shared/filters';
import type { FacetFilterKey } from '../shared/filters';
import {
fetchFacetedFilterStateWithFallback,
fetchPublishedSnapshotContext,
normaliseDateRange,
settledArrayWithFallback,
settledValueWithFallback,
} from '../shared/pageUtils';
Expand Down Expand Up @@ -106,7 +104,6 @@ export async function buildUserOverviewPage(
const shouldFetchCompleted = shouldFetchSection(requestedSection, 'user-overview-completed');
const shouldFetchCompletedByDate = shouldFetchSection(requestedSection, 'user-overview-completed-by-date');
const shouldFetchCompletedByTaskName = shouldFetchSection(requestedSection, 'user-overview-completed-by-task-name');
const range = normaliseDateRange({ from: filters.completedFrom, to: filters.completedTo });
const [assignedCountResult, completedCountResult] = await Promise.allSettled([
shouldFetchAssigned
? taskThinRepository.fetchUserOverviewAssignedTaskCount(
Expand Down Expand Up @@ -195,13 +192,12 @@ export async function buildUserOverviewPage(
)
: Promise.resolve([]),
shouldFetchCompleted
? completedComplianceSummaryService.fetchCompletedSummary(
? taskFactsRepository.fetchUserOverviewCompletedSummaryRows(
snapshotContext.snapshotId,
filters,
range,
USER_OVERVIEW_QUERY_OPTIONS
)
: Promise.resolve(null),
: Promise.resolve([]),
shouldFetchAssigned || shouldFetchCompleted ? courtVenueService.fetchCourtVenueDescriptions() : Promise.resolve({}),
shouldFetchAssigned || shouldFetchCompleted
? caseWorkerProfileService.fetchCaseWorkerProfileNames()
Expand Down Expand Up @@ -232,10 +228,10 @@ export async function buildUserOverviewPage(
'Failed to fetch user overview completed by task name rows from database',
[]
);
const completedCompliance = settledValueWithFallback(
const completedSummaryRows = settledValueWithFallback(
completedComplianceResult,
'Failed to fetch completed compliance summary from database',
null
'Failed to fetch user overview completed summary from database',
[]
);
const locationDescriptions = settledValueWithFallback(
locationDescriptionsResult,
Expand Down Expand Up @@ -297,12 +293,13 @@ export async function buildUserOverviewPage(
}),
{ tasks: 0, withinDue: 0 }
);
const completedSummary = completedSummaryRows[0];
const completedComplianceSummary = {
total: completedCompliance?.total ?? completedByDateTotals.tasks,
withinDueYes: completedCompliance?.within ?? completedByDateTotals.withinDue,
total: completedSummary?.total ?? completedByDateTotals.tasks,
withinDueYes: completedSummary?.within ?? completedByDateTotals.withinDue,
withinDueNo:
completedCompliance?.within !== undefined
? completedCompliance.total - completedCompliance.within
completedSummary?.within !== undefined
? completedSummary.total - completedSummary.within
: completedByDateTotals.tasks - completedByDateTotals.withinDue,
};

Expand Down
3 changes: 3 additions & 0 deletions src/test/routes/routeTestUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type RouteAnalyticsMocks = {
completedByNameRows?: unknown[];
userOverviewAssignedTaskRows?: unknown[];
userOverviewCompletedTaskRows?: unknown[];
userOverviewCompletedSummaryRows?: unknown[];
userOverviewAssignedTaskCount?: number;
userOverviewCompletedTaskCount?: number;
outstandingCriticalTaskRows?: unknown[];
Expand Down Expand Up @@ -56,6 +57,7 @@ function mockAnalyticsRepositories(analyticsMocks: RouteAnalyticsMocks = {}): vo
const completedByNameRows = analyticsMocks.completedByNameRows ?? [];
const userOverviewAssignedTaskRows = analyticsMocks.userOverviewAssignedTaskRows ?? [];
const userOverviewCompletedTaskRows = analyticsMocks.userOverviewCompletedTaskRows ?? [];
const userOverviewCompletedSummaryRows = analyticsMocks.userOverviewCompletedSummaryRows ?? [];
const userOverviewAssignedTaskCount = analyticsMocks.userOverviewAssignedTaskCount ?? 0;
const userOverviewCompletedTaskCount = analyticsMocks.userOverviewCompletedTaskCount ?? 0;
const outstandingCriticalTaskRows = analyticsMocks.outstandingCriticalTaskRows ?? [];
Expand Down Expand Up @@ -85,6 +87,7 @@ function mockAnalyticsRepositories(analyticsMocks: RouteAnalyticsMocks = {}): vo
fetchCompletedByNameRows: jest.fn().mockResolvedValue(completedByNameRows),
fetchCompletedByLocationRows: jest.fn().mockResolvedValue([]),
fetchCompletedByRegionRows: jest.fn().mockResolvedValue([]),
fetchUserOverviewCompletedSummaryRows: jest.fn().mockResolvedValue(userOverviewCompletedSummaryRows),
fetchUserOverviewCompletedTaskCount: jest.fn().mockResolvedValue(userOverviewCompletedTaskCount),
},
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('taskFactsRepository', () => {
await taskFactsRepository.fetchOpenTasksSummaryRows(snapshotId, {});
await taskFactsRepository.fetchTasksDuePriorityRows(snapshotId, {});
await taskFactsRepository.fetchCompletedSummaryRows(snapshotId, {}, range);
await taskFactsRepository.fetchUserOverviewCompletedSummaryRows(snapshotId, {});
await taskFactsRepository.fetchUserOverviewCompletedTaskCount(snapshotId, {});
await taskFactsRepository.fetchCompletedTimelineRows(snapshotId, {}, range);
await taskFactsRepository.fetchCompletedProcessingHandlingTimeRows(snapshotId, {}, range);
Expand Down Expand Up @@ -185,6 +186,38 @@ describe('taskFactsRepository', () => {
expect(query.values).toContain('JUDICIAL');
});

test('builds facts-backed user-overview completed summary query with filters and query options', async () => {
const completedFrom = new Date('2024-08-01');
const completedTo = new Date('2024-08-31');
(tmPrisma.$queryRaw as jest.Mock).mockResolvedValueOnce([{ total: 42, within: 30 }]);

const rows = await taskFactsRepository.fetchUserOverviewCompletedSummaryRows(
snapshotId,
{
service: ['Service A'],
user: ['user-1'],
completedFrom,
completedTo,
},
{ excludeRoleCategories: ['Judicial'] }
);
const query = queryCall();

expect(rows).toEqual([{ total: 42, within: 30 }]);
expect(query.sql).toContain('SELECT');
expect(query.sql).toContain('COALESCE(SUM(tasks), 0)::int AS total');
expect(query.sql).toContain('COALESCE(SUM(within_due), 0)::int AS within');
expect(query.sql).toContain('FROM analytics.snapshot_user_completed_facts');
expect(query.sql).toContain('snapshot_id =');
expect(query.sql).toContain('completed_date >=');
expect(query.sql).toContain('completed_date <=');
expect(query.sql).toContain('assignee IN');
expect(query.sql).toContain('UPPER(role_category_label) NOT IN');
expect(query.values).toEqual(
expect.arrayContaining([snapshotId, completedFrom, completedTo, 'user-1', 'Service A', 'JUDICIAL'])
);
});

test('builds facts-backed user-overview completed count query with filters and query options', async () => {
const completedFrom = new Date('2024-08-01');
const completedTo = new Date('2024-08-31');
Expand Down
36 changes: 15 additions & 21 deletions src/test/unit/analytics/userOverview/page.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { completedComplianceSummaryService } from '../../../../main/modules/analytics/completed/visuals/completedComplianceSummaryService';
import {
fetchFacetedFilterStateWithFallback as fetchFilterOptionsWithFallback,
fetchPublishedSnapshotContext,
Expand Down Expand Up @@ -35,6 +34,7 @@ jest.mock('../../../../main/modules/analytics/shared/services', () => ({

jest.mock('../../../../main/modules/analytics/shared/repositories', () => ({
taskFactsRepository: {
fetchUserOverviewCompletedSummaryRows: jest.fn(),
fetchUserOverviewCompletedTaskCount: jest.fn(),
},
taskThinRepository: {
Expand All @@ -46,10 +46,6 @@ jest.mock('../../../../main/modules/analytics/shared/repositories', () => ({
},
}));

jest.mock('../../../../main/modules/analytics/completed/visuals/completedComplianceSummaryService', () => ({
completedComplianceSummaryService: { fetchCompletedSummary: jest.fn() },
}));

describe('buildUserOverviewPage', () => {
const snapshotId = 104;
const userOverviewQueryOptions = { excludeRoleCategories: ['Judicial'] };
Expand Down Expand Up @@ -88,6 +84,7 @@ describe('buildUserOverviewPage', () => {
});
(taskThinRepository.fetchUserOverviewAssignedTaskCount as jest.Mock).mockResolvedValue(0);
(taskFactsRepository.fetchUserOverviewCompletedTaskCount as jest.Mock).mockResolvedValue(0);
(taskFactsRepository.fetchUserOverviewCompletedSummaryRows as jest.Mock).mockResolvedValue([]);
});

test('builds the assigned partial view model with filters and options', async () => {
Expand Down Expand Up @@ -267,9 +264,12 @@ describe('buildUserOverviewPage', () => {
expect(taskThinRepository.fetchUserOverviewCompletedTaskRows).not.toHaveBeenCalled();
});

test('supports legacy completed ajax section alias and clamps oversized pages', async () => {
test('supports legacy completed ajax section alias, applies the user filter to summary data, and clamps oversized pages', async () => {
const sort = getDefaultUserOverviewSort();
(taskFactsRepository.fetchUserOverviewCompletedTaskCount as jest.Mock).mockResolvedValue(20000);
(taskFactsRepository.fetchUserOverviewCompletedSummaryRows as jest.Mock).mockResolvedValue([
{ total: 1, within: 1 },
]);
(taskThinRepository.fetchUserOverviewCompletedTaskRows as jest.Mock).mockResolvedValue([
{
case_id: 'CASE-2',
Expand All @@ -291,40 +291,35 @@ describe('buildUserOverviewPage', () => {
},
]);
(taskThinRepository.fetchUserOverviewCompletedByDateRows as jest.Mock).mockResolvedValue([]);
(completedComplianceSummaryService.fetchCompletedSummary as jest.Mock).mockResolvedValue({
total: 1,
within: 1,
});
mockDefaultUserOverviewAggregate();
(courtVenueService.fetchCourtVenueDescriptions as jest.Mock).mockResolvedValue({});
(caseWorkerProfileService.fetchCaseWorkerProfileNames as jest.Mock).mockResolvedValue({
'user-1': 'Sam Taylor',
});
(buildUserOverviewViewModel as jest.Mock).mockReturnValue({ view: 'user-overview-completed-alias' });

await buildUserOverviewPage({}, sort, 1, 999, 'completed');
await buildUserOverviewPage({ user: ['user-1'] }, sort, 1, 999, 'completed');

expect(taskFactsRepository.fetchUserOverviewCompletedTaskCount).toHaveBeenCalledWith(
snapshotId,
{},
{ user: ['user-1'] },
userOverviewQueryOptions
);
expect(taskFactsRepository.fetchUserOverviewCompletedSummaryRows).toHaveBeenCalledWith(
snapshotId,
{ user: ['user-1'] },
userOverviewQueryOptions
);
expect(taskThinRepository.fetchUserOverviewCompletedTaskRows).toHaveBeenCalledWith(
snapshotId,
{},
{ user: ['user-1'] },
sort.completed,
{
page: 10,
pageSize: 50,
},
userOverviewQueryOptions
);
expect(completedComplianceSummaryService.fetchCompletedSummary).toHaveBeenCalledWith(
snapshotId,
{},
undefined,
userOverviewQueryOptions
);
expect(taskThinRepository.fetchUserOverviewCompletedByDateRows).not.toHaveBeenCalled();
expect(buildUserOverviewViewModel).toHaveBeenCalledWith(
expect.objectContaining({
Expand Down Expand Up @@ -430,7 +425,6 @@ describe('buildUserOverviewPage', () => {
handling_time_count: 0,
},
]);
(completedComplianceSummaryService.fetchCompletedSummary as jest.Mock).mockResolvedValue({ total: 1, within: 1 });
mockDefaultUserOverviewAggregate();
mockDefaultUserOverviewFilterState();
(courtVenueService.fetchCourtVenueDescriptions as jest.Mock).mockResolvedValue({});
Expand Down Expand Up @@ -487,7 +481,6 @@ describe('buildUserOverviewPage', () => {
},
]);
(taskThinRepository.fetchUserOverviewCompletedByTaskNameRows as jest.Mock).mockResolvedValue([]);
(completedComplianceSummaryService.fetchCompletedSummary as jest.Mock).mockResolvedValue(null);
mockDefaultUserOverviewAggregate();
mockDefaultUserOverviewFilterState();
(courtVenueService.fetchCourtVenueDescriptions as jest.Mock).mockResolvedValue({});
Expand All @@ -501,5 +494,6 @@ describe('buildUserOverviewPage', () => {
completedComplianceSummary: { total: 4, withinDueYes: 3, withinDueNo: 1 },
})
);
expect(taskFactsRepository.fetchUserOverviewCompletedSummaryRows).not.toHaveBeenCalled();
});
});