feat(campaign): filter reports from outcome counts

This commit is contained in:
2026-07-22 09:11:10 +02:00
parent 0b4017c240
commit 4eb651c6ac
4 changed files with 194 additions and 26 deletions

View File

@@ -28,6 +28,13 @@ import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { asRecord, formatDateTime, humanize } from "./utils/campaignView"; import { asRecord, formatDateTime, humanize } from "./utils/campaignView";
import { emptyCampaignJobsResponse } from "./utils/jobDeltas"; import { emptyCampaignJobsResponse } from "./utils/jobDeltas";
import type { CampaignJobSortColumn } from "./utils/jobListQuery"; import type { CampaignJobSortColumn } from "./utils/jobListQuery";
import {
DEFAULT_REPORT_GRID_SORT,
activeReportGridShortcut,
reportGridQueriesEqual,
toggleReportGridShortcut,
type ReportGridShortcutId
} from "./utils/reportGridShortcuts";
const SEND_STATUS_OPTIONS: DataGridListOption[] = [ const SEND_STATUS_OPTIONS: DataGridListOption[] = [
"not_queued", "not_queued",
@@ -70,7 +77,6 @@ const QUEUE_STATUS_OPTIONS: DataGridListOption[] = [
"cancelled"]. "cancelled"].
map((value) => ({ value, label: humanize(value) })); map((value) => ({ value, label: humanize(value) }));
const DEFAULT_JOB_GRID_SORT = { columnId: "number", direction: "asc" as const };
const JOB_GRID_QUERY_DELAY_MS = 300; const JOB_GRID_QUERY_DELAY_MS = 300;
type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null; type ReconcileRequest = {jobId: string;decision: "smtp_accepted" | "not_sent";} | null;
@@ -90,7 +96,7 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
const [pageSize, setPageSize] = useState(50); const [pageSize, setPageSize] = useState(50);
const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters()); const [initialGridFilters] = useState<Record<string, string | string[]>>(() => initialReportGridFilters());
const initialGridQuery = useMemo<DataGridQueryState>(() => ({ const initialGridQuery = useMemo<DataGridQueryState>(() => ({
sort: DEFAULT_JOB_GRID_SORT, sort: DEFAULT_REPORT_GRID_SORT,
filters: serializeInitialGridFilters(initialGridFilters) filters: serializeInitialGridFilters(initialGridFilters)
}), [initialGridFilters]); }), [initialGridFilters]);
const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery); const [jobGridQuery, setJobGridQuery] = useState<DataGridQueryState>(initialGridQuery);
@@ -110,16 +116,45 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
useEffect(() => { useEffect(() => {
const handle = window.setTimeout(() => { const handle = window.setTimeout(() => {
setAppliedQuery(query.trim()); setAppliedQuery(query.trim());
setAppliedJobGridQuery((current) => dataGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery); setAppliedJobGridQuery((current) => reportGridQueriesEqual(current, jobGridQuery) ? current : jobGridQuery);
setPage(1); setPage(1);
}, JOB_GRID_QUERY_DELAY_MS); }, JOB_GRID_QUERY_DELAY_MS);
return () => window.clearTimeout(handle); return () => window.clearTimeout(handle);
}, [query, jobGridQuery]); }, [query, jobGridQuery]);
const handleJobGridQuery = useCallback((next: DataGridQueryState) => { const handleJobGridQuery = useCallback((next: DataGridQueryState) => {
setJobGridQuery((current) => dataGridQueriesEqual(current, next) ? current : next); setJobGridQuery((current) => reportGridQueriesEqual(current, next) ? current : next);
}, []); }, []);
const activeJobGridShortcut = useMemo(
() => query.trim() ? null : activeReportGridShortcut(jobGridQuery),
[jobGridQuery, query]
);
const applyJobGridShortcut = useCallback((shortcutId: ReportGridShortcutId) => {
const next = toggleReportGridShortcut(jobGridQuery, shortcutId);
setQuery("");
setAppliedQuery("");
setJobGridQuery(next);
setAppliedJobGridQuery(next);
setPage(1);
}, [jobGridQuery]);
const deliveryOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.jobs_total.98da65bc", value: cards?.jobs_total ?? "—", shortcutId: "all" },
{ label: "i18n:govoplan-campaign.smtp_accepted.e3aa7603", value: cards?.smtp_accepted ?? cards?.sent ?? 0, shortcutId: "smtp_accepted" },
{ label: "i18n:govoplan-campaign.failed.09fef5d8", value: cards?.failed ?? 0, shortcutId: "failed" },
{ label: "i18n:govoplan-campaign.outcome_unknown.6e929fca", value: cards?.outcome_unknown ?? 0, shortcutId: "outcome_unknown" },
{ label: "i18n:govoplan-campaign.not_attempted.e1be3c69", value: cards?.not_attempted ?? 0, shortcutId: "not_attempted" },
{ label: "i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19", value: cards?.skipped ?? jobs.counts.send?.skipped ?? 0, shortcutId: "smtp_skipped" },
{ label: "i18n:govoplan-campaign.cancelled.a1bf92ef", value: cards?.cancelled ?? 0, shortcutId: "cancelled" }
];
const imapOutcomeShortcuts: { label: string; value: string | number; shortcutId: ReportGridShortcutId }[] = [
{ label: "i18n:govoplan-campaign.imap_appended.56017ea3", value: cards?.imap_appended ?? 0, shortcutId: "imap_appended" },
{ label: "i18n:govoplan-campaign.imap_failed.50dbca55", value: cards?.imap_failed ?? 0, shortcutId: "imap_failed" },
{ label: "i18n:govoplan-campaign.imap_skipped.5a97b542", value: cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0, shortcutId: "imap_skipped" }
];
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
if (!campaignId) return; if (!campaignId) return;
const requestId = ++jobsRequestRef.current; const requestId = ++jobsRequestRef.current;
@@ -383,20 +418,36 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
<Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085"> <Card title="i18n:govoplan-campaign.delivery_outcome.f9d7c085">
<dl className="detail-list"> <dl className="detail-list">
<div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div> <div><dt>i18n:govoplan-campaign.generated.8eefdd52</dt><dd>{formatDateTime(data.summary?.generated_at)}</dd></div>
<div><dt>i18n:govoplan-campaign.jobs_total.98da65bc</dt><dd>{cards?.jobs_total ?? "—"}</dd></div> {deliveryOutcomeShortcuts.map(({ label, value, shortcutId }) => {
<div><dt>i18n:govoplan-campaign.smtp_accepted.e3aa7603</dt><dd>{cards?.smtp_accepted ?? cards?.sent ?? 0}</dd></div> const active = activeJobGridShortcut === shortcutId;
<div><dt>i18n:govoplan-campaign.failed.09fef5d8</dt><dd>{cards?.failed ?? 0}</dd></div> return (
<div><dt>i18n:govoplan-campaign.outcome_unknown.6e929fca</dt><dd>{cards?.outcome_unknown ?? 0}</dd></div> <div key={shortcutId}>
<div><dt>i18n:govoplan-campaign.not_attempted.e1be3c69</dt><dd>{cards?.not_attempted ?? 0}</dd></div> <dt>{label}</dt>
<div><dt>i18n:govoplan-campaign.smtp_skipped_excluded_.df6eca19</dt><dd>{cards?.skipped ?? jobs.counts.send?.skipped ?? 0}</dd></div> <dd>
<div><dt>i18n:govoplan-campaign.cancelled.a1bf92ef</dt><dd>{cards?.cancelled ?? 0}</dd></div> <Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
</dl> </dl>
</Card> </Card>
<Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058"> <Card title="i18n:govoplan-campaign.imap_and_execution_plan.4c80c058">
<dl className="detail-list"> <dl className="detail-list">
<div><dt>i18n:govoplan-campaign.imap_appended.56017ea3</dt><dd>{cards?.imap_appended ?? 0}</dd></div> {imapOutcomeShortcuts.map(({ label, value, shortcutId }) => {
<div><dt>i18n:govoplan-campaign.imap_failed.50dbca55</dt><dd>{cards?.imap_failed ?? 0}</dd></div> const active = activeJobGridShortcut === shortcutId;
<div><dt>i18n:govoplan-campaign.imap_skipped.5a97b542</dt><dd>{cards?.imap_skipped ?? jobs.counts.imap?.skipped ?? 0}</dd></div> return (
<div key={shortcutId}>
<dt>{label}</dt>
<dd>
<Button type="button" variant={active ? "primary" : "ghost"} aria-pressed={active} onClick={() => applyJobGridShortcut(shortcutId)}>
{value}
</Button>
</dd>
</div>
);
})}
<div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div> <div><dt>i18n:govoplan-campaign.append_policy.f195cb05</dt><dd>{imapPolicy.enabled === true ? i18nMessage("i18n:govoplan-campaign.enabled_value.e395e48f", { value0: String(imapPolicy.folder ?? "i18n:govoplan-campaign.auto.0d612c12") }) : "i18n:govoplan-campaign.disabled.f4f4473d"}</dd></div>
<div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div> <div><dt>i18n:govoplan-campaign.rate_limit.d08e55f5</dt><dd>{rateLimit.messages_per_minute ? i18nMessage("i18n:govoplan-campaign.value_minute.aeb1a9ea", { value0: String(rateLimit.messages_per_minute) }) : "—"}</dd></div>
<div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div> <div><dt>i18n:govoplan-campaign.minimum_remaining_duration.639b792c</dt><dd>{String(delivery.estimated_remaining_send_human ?? "—")}</dd></div>
@@ -435,7 +486,8 @@ export default function CampaignReportPage({ settings, campaignId }: {settings:
getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")} getRowKey={(row: Record<string, unknown>) => String(row.id ?? "")}
emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5" emptyText="i18n:govoplan-campaign.no_jobs_match_the_current_filters.b1501ff5"
initialFilters={initialGridFilters} initialFilters={initialGridFilters}
initialSort={DEFAULT_JOB_GRID_SORT} initialSort={DEFAULT_REPORT_GRID_SORT}
query={jobGridQuery}
pagination={{ pagination={{
mode: "server", mode: "server",
page, page,
@@ -589,16 +641,6 @@ function campaignJobSortColumn(value?: string): CampaignJobSortColumn {
return "number"; return "number";
} }
function dataGridQueriesEqual(left: DataGridQueryState, right: DataGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function retryableFailedStatus(status: string): boolean { function retryableFailedStatus(status: string): boolean {
return status === "failed_temporary" || status === "failed_permanent"; return status === "failed_temporary" || status === "failed_permanent";
} }

View File

@@ -0,0 +1,85 @@
export type ReportGridQueryState = {
sort: { columnId: string; direction: "asc" | "desc" } | null;
filters: Record<string, string>;
};
export const DEFAULT_REPORT_GRID_SORT = { columnId: "number", direction: "asc" as const };
export type ReportGridShortcutId =
| "all"
| "smtp_accepted"
| "failed"
| "outcome_unknown"
| "not_attempted"
| "smtp_skipped"
| "cancelled"
| "imap_appended"
| "imap_failed"
| "imap_skipped";
const REPORT_GRID_SHORTCUT_FILTERS: Record<ReportGridShortcutId, Record<string, string>> = {
all: {},
smtp_accepted: { send: listFilter(["smtp_accepted", "sent"]) },
failed: { send: listFilter(["failed_temporary", "failed_permanent"]) },
outcome_unknown: { send: listFilter(["outcome_unknown"]) },
not_attempted: { send: listFilter(["not_queued"]) },
smtp_skipped: { send: listFilter(["skipped"]) },
cancelled: { send: listFilter(["cancelled"]) },
imap_appended: { imap: listFilter(["appended"]) },
imap_failed: { imap: listFilter(["failed"]) },
imap_skipped: { imap: listFilter(["skipped"]) }
};
/**
* Return the complete grid query for a count shortcut. Shortcuts are exact
* report views, so applying one intentionally clears every unrelated filter
* and restores the stable report ordering.
*/
export function reportGridQueryForShortcut(shortcutId: ReportGridShortcutId): ReportGridQueryState {
return {
sort: { ...DEFAULT_REPORT_GRID_SORT },
filters: { ...REPORT_GRID_SHORTCUT_FILTERS[shortcutId] }
};
}
/** Applying an already selected outcome shortcut returns to the unfiltered report. */
export function toggleReportGridShortcut(
current: ReportGridQueryState,
shortcutId: ReportGridShortcutId
): ReportGridQueryState {
const target = reportGridQueryForShortcut(shortcutId);
if (shortcutId !== "all" && reportGridFiltersEqual(current.filters, target.filters)) {
return reportGridQueryForShortcut("all");
}
return target;
}
export function activeReportGridShortcut(query: ReportGridQueryState): ReportGridShortcutId | null {
const shortcutIds = Object.keys(REPORT_GRID_SHORTCUT_FILTERS) as ReportGridShortcutId[];
return shortcutIds.find((shortcutId) => reportGridFiltersEqual(
query.filters,
REPORT_GRID_SHORTCUT_FILTERS[shortcutId]
)) ?? null;
}
export function reportGridQueriesEqual(left: ReportGridQueryState, right: ReportGridQueryState): boolean {
if ((left.sort?.columnId ?? "") !== (right.sort?.columnId ?? "")) return false;
if ((left.sort?.direction ?? "") !== (right.sort?.direction ?? "")) return false;
const keys = new Set([...Object.keys(left.filters), ...Object.keys(right.filters)]);
for (const key of keys) {
if ((left.filters[key] ?? "") !== (right.filters[key] ?? "")) return false;
}
return true;
}
function listFilter(values: string[]): string {
return `list:${JSON.stringify(values)}`;
}
function reportGridFiltersEqual(left: Record<string, string>, right: Record<string, string>): boolean {
const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
for (const key of keys) {
if ((left[key] ?? "") !== (right[key] ?? "")) return false;
}
return true;
}

View File

@@ -1,4 +1,9 @@
import { campaignJobsQueryParams } from "../src/features/campaigns/utils/jobListQuery"; import { campaignJobsQueryParams } from "../src/features/campaigns/utils/jobListQuery";
import {
activeReportGridShortcut,
reportGridQueryForShortcut,
toggleReportGridShortcut
} from "../src/features/campaigns/utils/reportGridShortcuts";
declare function require(name: string): { declare function require(name: string): {
readFileSync(path: string, encoding: string): string; readFileSync(path: string, encoding: string): string;
@@ -34,12 +39,47 @@ assert(params.get("filter_send") === 'list:["failed_temporary","outcome_unknown"
assert(params.get("filter_attempts") === "gte:2", "typed number filters retain their operator"); assert(params.get("filter_attempts") === "gte:2", "typed number filters retain their operator");
assert(!params.toString().includes("unsupported") && !params.toString().includes("must-not-leak"), "only the declared backend filter contract is serialized"); assert(!params.toString().includes("unsupported") && !params.toString().includes("must-not-leak"), "only the declared backend filter contract is serialized");
const shortcutFilters = {
smtp_accepted: { send: 'list:["smtp_accepted","sent"]' },
failed: { send: 'list:["failed_temporary","failed_permanent"]' },
outcome_unknown: { send: 'list:["outcome_unknown"]' },
not_attempted: { send: 'list:["not_queued"]' },
smtp_skipped: { send: 'list:["skipped"]' },
cancelled: { send: 'list:["cancelled"]' },
imap_appended: { imap: 'list:["appended"]' },
imap_failed: { imap: 'list:["failed"]' },
imap_skipped: { imap: 'list:["skipped"]' }
} as const;
for (const [shortcutId, filters] of Object.entries(shortcutFilters)) {
const shortcut = reportGridQueryForShortcut(shortcutId as keyof typeof shortcutFilters);
assert(JSON.stringify(shortcut.filters) === JSON.stringify(filters), `${shortcutId} uses the exact backend list filter`);
assert(shortcut.sort?.columnId === "number" && shortcut.sort.direction === "asc", `${shortcutId} restores stable report ordering`);
}
const failedShortcut = toggleReportGridShortcut({
sort: { columnId: "updated", direction: "desc" },
filters: { recipient: "old search", imap: 'list:["appended"]' }
}, "failed");
assert(JSON.stringify(failedShortcut.filters) === JSON.stringify(shortcutFilters.failed), "a shortcut clears unrelated filters");
assert(activeReportGridShortcut(failedShortcut) === "failed", "the exact shortcut query is recognized as active");
const toggledBack = toggleReportGridShortcut(failedShortcut, "failed");
assert(Object.keys(toggledBack.filters).length === 0, "selecting an active count returns to all jobs");
assert(activeReportGridShortcut(toggledBack) === "all", "the cleared query activates the all-jobs shortcut");
const sortedFailedShortcut = { ...failedShortcut, sort: { columnId: "recipient", direction: "desc" as const } };
assert(Object.keys(toggleReportGridShortcut(sortedFailedShortcut, "failed").filters).length === 0, "the active shortcut toggles to all even after the user changes sorting");
const reportSource = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8"); const reportSource = readFileSync("src/features/campaigns/CampaignReportPage.tsx", "utf8");
assert(reportSource.includes('mode: "server"'), "the report DataGrid declares server query ownership"); assert(reportSource.includes('mode: "server"'), "the report DataGrid declares server query ownership");
assert(reportSource.includes("totalRows: jobs.total"), "the shared pagination count uses the filtered backend total"); assert(reportSource.includes("totalRows: jobs.total"), "the shared pagination count uses the filtered backend total");
assert(reportSource.includes("onQueryChange={handleJobGridQuery}"), "header sort and filter changes drive the backend query"); assert(reportSource.includes("onQueryChange={handleJobGridQuery}"), "header sort and filter changes drive the backend query");
assert(reportSource.includes("initialReportGridFilters()"), "status deep links initialize the DataGrid filters"); assert(reportSource.includes("initialReportGridFilters()"), "status deep links initialize the DataGrid filters");
assert(reportSource.includes("initialReportQuery()"), "q deep links initialize the report search"); assert(reportSource.includes("initialReportQuery()"), "q deep links initialize the report search");
assert(reportSource.includes("query={jobGridQuery}"), "external count shortcuts synchronize the visible DataGrid query");
assert(reportSource.includes('setQuery("");') && reportSource.includes('setAppliedQuery("");'), "count shortcuts clear both visible and applied search terms");
assert(reportSource.includes("deliveryOutcomeShortcuts.map") && reportSource.includes("imapOutcomeShortcuts.map"), "top-level delivery counts use one coherent shortcut model");
assert(reportSource.includes('<Button type="button" variant={active ? "primary" : "ghost"}'), "count shortcuts render the central Button directly");
assert(reportSource.includes('aria-pressed={active}'), "count shortcuts expose their selected state accessibly");
assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option"); assert(reportSource.includes('"skipped",\n"queued"'), "SMTP skipped is a first-class report filter option");
assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog"); assert(reportSource.includes("i18n:govoplan-campaign.excluded_rows_are_intentionally_omitted_from_del.421a1f00"), "the report explains excluded transport semantics through the bilingual catalog");
assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label"); assert(reportSource.includes('return status === "skipped" ? "i18n:govoplan-campaign.skipped.5a000ad7"'), "the new skipped filter and status badges use the localized label");

View File

@@ -15,6 +15,7 @@
}, },
"include": [ "include": [
"tests/report-grid-query.test.ts", "tests/report-grid-query.test.ts",
"src/features/campaigns/utils/jobListQuery.ts" "src/features/campaigns/utils/jobListQuery.ts",
"src/features/campaigns/utils/reportGridShortcuts.ts"
] ]
} }