Component props including data sources and configuration.
Dropdown button element.
export function ExportStatisticsButton({
importStats,
syncStats,
matchResults,
disabled = false,
size = "default",
variant = "outline",
appliedFilters,
comparisonMode,
isFiltered,
}: Readonly<ExportStatisticsButtonProps>) {
const [format, setFormat] = useState<StatisticsExportFormat>("json");
const [selectedSections, setSelectedSections] = useState<Set<ExportSection>>(
() => new Set<ExportSection>(["import", "sync", "matches"]),
);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const matchCount = useMemo(() => matchResults.length, [matchResults]);
const toggleExportSection = useCallback((section: ExportSection) => {
setSelectedSections((prev) => {
const next = new Set(prev);
if (next.has(section)) {
next.delete(section);
} else {
next.add(section);
}
return next;
});
}, []);
const buildJsonPayload = useCallback(() => {
const baseMetadata = buildExportMetadata(
"json",
matchResults.length,
undefined,
Array.from(selectedSections),
);
const enrichedMetadata = {
...baseMetadata,
...(appliedFilters && { filters: appliedFilters }),
...(comparisonMode?.enabled && { comparison: comparisonMode }),
...(isFiltered && { isFiltered: true }),
};
const payload: Record<string, unknown> = {
metadata: enrichedMetadata,
generatedAt: new Date().toISOString(),
};
if (selectedSections.has("import") && importStats) {
payload.importStats = importStats;
}
if (selectedSections.has("sync") && syncStats) {
payload.syncStats = syncStats;
}
if (selectedSections.has("matches") && matchResults.length > 0) {
payload.matchResults = matchResults;
}
return payload;
}, [
importStats,
syncStats,
matchResults,
selectedSections,
appliedFilters,
comparisonMode,
isFiltered,
]);
const buildTabularRows = useCallback((): ExportRow[] => {
const summaryRows = buildSummaryRows(
importStats,
syncStats,
selectedSections,
);
if (selectedSections.has("matches") && matchResults.length > 0) {
summaryRows.push(...buildMatchRows(matchResults));
}
return summaryRows;
}, [importStats, syncStats, selectedSections, matchResults]);
/**
* Handles JSON export format.
* @returns Promise that resolves when export is complete
*/
const handleJsonExport = useCallback(async (): Promise<void> => {
const payload = buildJsonPayload();
const file = await exportToJson(payload, "statistics");
toast.success(`Statistics exported to ${file}`);
setIsMenuOpen(false);
}, [buildJsonPayload]);
/**
* Builds metadata comments for CSV export including filters and comparison mode.
* @param baseMetadata - Base export metadata
* @param sectionNames - Array of export section names
* @returns Array of comment rows with metadata
*/
const buildCsvMetadataComments = useCallback(
(
baseMetadata: Record<string, unknown>,
sectionNames: string[],
): Array<Record<string, unknown>> => {
const baseComments: Array<Record<string, unknown>> = [
{
comment: `Exported: ${baseMetadata.exportedAt ?? new Date().toISOString()}`,
},
{ comment: `App Version: v${baseMetadata.appVersion ?? "unknown"}` },
{ comment: `Sections: ${sectionNames.join(", ")}` },
];
const filterComments: Array<Record<string, unknown>> = appliedFilters
? [
{ comment: "" },
{ comment: "Filters Applied:" },
{
comment:
appliedFilters.genres.length > 0
? ` Genres: ${appliedFilters.genres.join(", ")}`
: " Genres: None",
},
{
comment:
appliedFilters.formats.length > 0
? ` Formats: ${appliedFilters.formats.join(", ")}`
: " Formats: None",
},
{
comment:
appliedFilters.statuses.length > 0
? ` Statuses: ${appliedFilters.statuses.join(", ")}`
: " Statuses: None",
},
{
comment:
appliedFilters.dateRange.start || appliedFilters.dateRange.end
? ` Date Range: ${appliedFilters.dateRange.start?.toISOString().split("T")[0] ?? "N/A"} to ${appliedFilters.dateRange.end?.toISOString().split("T")[0] ?? "N/A"}`
: " Date Range: None",
},
{
comment: ` Confidence: ${appliedFilters.confidenceRange.min} - ${appliedFilters.confidenceRange.max}`,
},
]
: [];
const comparisonComments: Array<Record<string, unknown>> =
comparisonMode?.enabled
? [
{ comment: "" },
{ comment: "Comparison Mode:" },
{ comment: ` Primary Range: ${comparisonMode.primaryRange}` },
{
comment: ` Secondary Range: ${comparisonMode.secondaryRange}`,
},
{ comment: ` Metric: ${comparisonMode.metric}` },
]
: [];
return [
...baseComments,
...filterComments,
...comparisonComments,
{ comment: "" },
];
},
[appliedFilters, comparisonMode],
);
/**
* Handles CSV export format.
* @param sectionNames - Array of export section names
* @returns Promise that resolves when export is complete
*/
const handleCsvExport = useCallback(
async (selectedSectionNames: string[]): Promise<void> => {
const rows = buildTabularRows();
if (rows.length === 0) {
toast.error("No data available for the selected export format");
return;
}
const baseMetadata = buildExportMetadata(
"csv",
matchResults.length,
undefined,
selectedSectionNames,
);
const metadataComments = buildCsvMetadataComments(
baseMetadata as unknown as Record<string, unknown>,
selectedSectionNames,
);
const withMetadata = [...metadataComments, ...rows];
const tabularData = withMetadata as unknown as Record<string, unknown>[];
const file = await exportToCSV(tabularData, "statistics");
toast.success(`Statistics exported to ${file}`);
setIsMenuOpen(false);
},
[buildTabularRows, matchResults.length, buildCsvMetadataComments],
);
/**
* Handles Markdown export format.
* @param sectionNames - Array of export section names
* @param totalEntries - Total number of entries being exported
* @returns Promise that resolves when export is complete
*/
const handleMarkdownExport = useCallback(
async (
selectedSectionNames: string[],
totalEntries: number,
): Promise<void> => {
const baseMetadata = buildExportMetadata(
"markdown",
totalEntries,
undefined,
selectedSectionNames,
);
const markdownData: Record<string, unknown> = {};
if (appliedFilters) {
markdownData.appliedFilters = appliedFilters;
}
if (comparisonMode?.enabled) {
markdownData.comparisonMode = comparisonMode;
}
if (isFiltered) {
markdownData.isFiltered = true;
}
if (selectedSections.has("import") && importStats) {
markdownData.importStats = importStats;
}
if (selectedSections.has("sync") && syncStats) {
markdownData.syncStats = syncStats;
}
if (selectedSections.has("matches") && matchResults.length > 0) {
const flattened = matchResults.map(flattenMatchResult);
markdownData.matchResults = flattened;
}
const file = exportToMarkdown(markdownData, "statistics", baseMetadata);
toast.success(`Statistics exported to ${file}`);
setIsMenuOpen(false);
},
[
selectedSections,
appliedFilters,
comparisonMode,
isFiltered,
importStats,
syncStats,
matchResults,
],
);
const handleExport = useCallback(async () => {
if (selectedSections.size === 0) {
toast.error("Select at least one dataset to export");
return;
}
try {
const selectedSectionNames = Array.from(selectedSections);
const totalEntries = matchResults.length;
if (format === "json") {
await handleJsonExport();
return;
}
if (format === "markdown") {
await handleMarkdownExport(selectedSectionNames, totalEntries);
return;
}
await handleCsvExport(selectedSectionNames);
} catch (error) {
console.error("[ExportStatistics] ❌ Export failed", error);
toast.error("Failed to export statistics");
}
}, [
selectedSections,
format,
matchResults,
handleJsonExport,
handleMarkdownExport,
handleCsvExport,
]);
return (
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<DropdownMenuTrigger asChild>
<Button
variant={variant}
size={size}
disabled={disabled}
className="gap-2"
>
<Download className="h-4 w-4" aria-hidden="true" />
<span>
{isFiltered ? "Export Filtered Statistics" : "Export Statistics"}
</span>
<span className="text-muted-foreground text-xs font-medium">
{selectedSections.size} dataset
{selectedSections.size === 1 ? "" : "s"}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-blue-500" aria-hidden="true" />
Export Options
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={format}
onValueChange={(value) => setFormat(value as StatisticsExportFormat)}
>
<DropdownMenuRadioItem value="json">
<FileJson
className="mr-2 h-4 w-4 text-emerald-500"
aria-hidden="true"
/>
JSON
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="csv">
<FileSpreadsheet
className="mr-2 h-4 w-4 text-blue-500"
aria-hidden="true"
/>
CSV
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="markdown">
<FileText
className="mr-2 h-4 w-4 text-purple-500"
aria-hidden="true"
/>
Markdown
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{/* Filter Summary Section */}
{isFiltered && appliedFilters && (
<>
<DropdownMenuLabel className="flex items-center gap-2 text-xs">
<span className="font-medium">Applied Filters</span>
</DropdownMenuLabel>
{appliedFilters.genres.length > 0 && (
<div className="px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
Genres: {appliedFilters.genres.join(", ")}
</div>
)}
{appliedFilters.formats.length > 0 && (
<div className="px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
Formats: {appliedFilters.formats.join(", ")}
</div>
)}
{appliedFilters.statuses.length > 0 && (
<div className="px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
Statuses: {appliedFilters.statuses.join(", ")}
</div>
)}
{(appliedFilters.dateRange.start ||
appliedFilters.dateRange.end) && (
<div className="px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
Date Range:{" "}
{appliedFilters.dateRange.start?.toISOString().split("T")[0] ??
"N/A"}{" "}
to{" "}
{appliedFilters.dateRange.end?.toISOString().split("T")[0] ??
"N/A"}
</div>
)}
{(appliedFilters.confidenceRange.min > 0 ||
appliedFilters.confidenceRange.max < 100) && (
<div className="px-2 py-1 text-xs text-slate-600 dark:text-slate-400">
Confidence: {appliedFilters.confidenceRange.min} -{" "}
{appliedFilters.confidenceRange.max}
</div>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-slate-500" aria-hidden="true" />
Include Sections
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={selectedSections.has("import")}
onCheckedChange={() => toggleExportSection("import")}
>
<BarChart2
className="mr-2 h-4 w-4 text-blue-500"
aria-hidden="true"
/>
Import statistics
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedSections.has("sync")}
onCheckedChange={() => toggleExportSection("sync")}
>
<Activity
className="mr-2 h-4 w-4 text-emerald-500"
aria-hidden="true"
/>
Sync performance
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={selectedSections.has("matches")}
onCheckedChange={() => toggleExportSection("matches")}
disabled={matchCount === 0}
>
<BarChart3
className="mr-2 h-4 w-4 text-purple-500"
aria-hidden="true"
/>
Match results
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleExport}
className="bg-primary/5 text-primary hover:bg-primary/10 cursor-pointer font-medium"
>
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
Export now
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
ExportStatisticsButton provides a dropdown-driven control for exporting statistics data. Users can choose formats and which data sections to include.