ChemPal Documentation - v0.0.13-beta.5
    Preparing search index...

    Variable StatsPanelConst

    StatsPanel: FC = ...

    StatsPanel displays per-supplier search statistics as a full panel view. Three tabs: By Supplier (pie), Daily (bar), Totals (text). The pie chart has two views toggled like the Titanic example:

    • "HTTP Calls": inner = supplier totals, outer = success vs failure
    • "Parsed Data": inner = supplier totals, outer = products vs parse errors
    const StatsPanel: FC = () => {
    const appContext = useAppContext();
    const [stats, setStats] = useState<SupplierStatsData>({});
    const [activeTab, setActiveTab] = useState(0);
    const [pieView, setPieView] = useState<PieView>("http");

    useEffect(() => {
    const loadStats = async () => {
    try {
    const data = await getStats();
    setStats(data);
    } catch (error) {
    console.warn(error);
    }
    };
    loadStats();
    // Re-read stats when supplier stats are updated in IndexedDB (live updates during search)
    const handler = () => loadStats();
    window.addEventListener(IDB_SUPPLIER_STATS_UPDATED, handler);
    return () => window.removeEventListener(IDB_SUPPLIER_STATS_UPDATED, handler);
    }, []);

    const handleClear = async () => {
    try {
    await clearStats();
    setStats({});
    } catch (error) {
    console.warn(error);
    }
    };

    // Aggregate stats across all days
    const aggregatedBySupplier = useMemo(() => {
    const agg: Record<string, { success: number; failure: number; products: number; queries: number; parseErrors: number }> = {};
    for (const dayStats of Object.values(stats)) {
    for (const [supplier, s] of Object.entries(dayStats)) {
    if (!agg[supplier]) agg[supplier] = { success: 0, failure: 0, products: 0, queries: 0, parseErrors: 0 };
    agg[supplier].success += s.successCount;
    agg[supplier].failure += s.failureCount;
    agg[supplier].products += s.uniqueProductCount;
    agg[supplier].queries += s.searchQueryCount ?? 0;
    agg[supplier].parseErrors += s.parseErrorCount ?? 0;
    }
    }
    return agg;
    }, [stats]);

    // Build supplier color map
    const supplierColorMap = useMemo(() => {
    const suppliers = Object.keys(aggregatedBySupplier);
    const colorMap: Record<string, string> = {};
    suppliers.forEach((s, i) => {
    colorMap[s] = SUPPLIER_COLORS[i % SUPPLIER_COLORS.length];
    });
    return colorMap;
    }, [aggregatedBySupplier]);

    // ── HTTP Calls view: inner = total HTTP calls per supplier, outer = success vs failure ──
    const httpPieData = useMemo(() => {
    const suppliers = Object.keys(aggregatedBySupplier);
    const totalCalls = suppliers.reduce((sum, s) => sum + aggregatedBySupplier[s].success + aggregatedBySupplier[s].failure, 0);

    const inner: PieDatum[] = suppliers.map((supplier) => {
    const { success, failure } = aggregatedBySupplier[supplier];
    const total = success + failure;
    return {
    id: supplier,
    value: total,
    label: `${supplier}:`,
    percentage: totalCalls > 0 ? (total / totalCalls) * 100 : 0,
    color: supplierColorMap[supplier],
    };
    });

    // Exactly 2 entries per supplier (Yes/No pattern from Titanic example) so outer aligns with inner
    const outer: PieDatum[] = suppliers.flatMap((supplier) => {
    const { success, failure } = aggregatedBySupplier[supplier];
    const supplierTotal = success + failure;
    const baseColor = supplierColorMap[supplier];
    return [
    {
    id: `${supplier}-success`,
    label: "Successes",
    value: success,
    percentage: supplierTotal > 0 ? (success / supplierTotal) * 100 : 0,
    color: baseColor,
    },
    {
    id: `${supplier}-failure`,
    label: "Fails",
    value: failure,
    percentage: supplierTotal > 0 ? (failure / supplierTotal) * 100 : 0,
    color: hexToRgba(baseColor, 0.4),
    },
    ];
    });

    return { inner, outer, totalCalls };
    }, [aggregatedBySupplier, supplierColorMap]);

    // ── Parsed Data view: inner = total products + parse errors per supplier, outer = products vs parseErrors ──
    const parsedPieData = useMemo(() => {
    const suppliers = Object.keys(aggregatedBySupplier);
    const totalParsed = suppliers.reduce((sum, s) => sum + aggregatedBySupplier[s].products + aggregatedBySupplier[s].parseErrors, 0);

    const inner: PieDatum[] = suppliers.map((supplier) => {
    const { products, parseErrors } = aggregatedBySupplier[supplier];
    const total = products + parseErrors;
    return {
    id: supplier,
    value: total,
    label: `${supplier}:`,
    percentage: totalParsed > 0 ? (total / totalParsed) * 100 : 0,
    color: supplierColorMap[supplier],
    };
    });

    const outer: PieDatum[] = suppliers.flatMap((supplier) => {
    const { products, parseErrors } = aggregatedBySupplier[supplier];
    const supplierTotal = products + parseErrors;
    const baseColor = supplierColorMap[supplier];
    return [
    {
    id: `${supplier}-parsed`,
    label: "Successes",
    value: products,
    percentage: supplierTotal > 0 ? (products / supplierTotal) * 100 : 0,
    color: baseColor,
    },
    {
    id: `${supplier}-parseError`,
    label: "Fails",
    value: parseErrors,
    percentage: supplierTotal > 0 ? (parseErrors / supplierTotal) * 100 : 0,
    color: hexToRgba(baseColor, 0.4),
    },
    ];
    });

    return { inner, outer, totalParsed };
    }, [aggregatedBySupplier, supplierColorMap]);

    // Line chart data — one line per supplier showing total calls over time
    const { lineSeries, lineDates } = useMemo(() => {
    const sortedDates = Object.keys(stats).sort();

    const allSuppliers = new Set<string>();
    for (const dayStats of Object.values(stats)) {
    for (const supplier of Object.keys(dayStats)) {
    allSuppliers.add(supplier);
    }
    }

    const series: Array<{ data: number[]; label: string; color: string; showMark: boolean; valueFormatter: (v: number | null) => string }> = [];
    let colorIdx = 0;
    for (const supplier of allSuppliers) {
    const color = SUPPLIER_COLORS[colorIdx % SUPPLIER_COLORS.length];
    series.push({
    data: sortedDates.map((date) => {
    const s = stats[date]?.[supplier];
    return s ? s.successCount + s.failureCount : 0;
    }),
    label: supplier,
    color,
    showMark: true,
    valueFormatter: (v) => {
    if (!v) return "0 calls";
    return `${v} calls`;
    },
    });
    colorIdx++;
    }

    return { lineSeries: series, lineDates: sortedDates.map((d) => d.slice(5)) };
    }, [stats]);

    // Totals table
    const totalsColumns: GridColDef[] = [
    { field: "supplier", headerName: "Supplier", flex: 1, minWidth: 130 },
    { field: "queries", headerName: "Queries", width: 80, type: "number" },
    { field: "success", headerName: "Success", width: 80, type: "number" },
    { field: "failure", headerName: "Failure", width: 80, type: "number" },
    { field: "products", headerName: "Products", width: 85, type: "number" },
    { field: "parseErrors", headerName: "Parse Errors", width: 105, type: "number" },
    ];

    const totalsRows = useMemo(() =>
    Object.entries(aggregatedBySupplier).map(([supplier, s]) => ({
    id: supplier,
    supplier,
    queries: s.queries,
    success: s.success,
    failure: s.failure,
    products: s.products,
    parseErrors: s.parseErrors,
    })),
    [aggregatedBySupplier]);

    const hasData = Object.keys(stats).length > 0;
    const totalCalls = Object.values(aggregatedBySupplier).reduce((sum, s) => sum + s.success + s.failure, 0);

    const innerRadius = 50;
    const middleRadius = 120;

    // Select the right pie data based on the toggle
    const activePie = pieView === "http" ? httpPieData : parsedPieData;
    const centerLabel = pieView === "http" ? "HTTP" : "Parsed";
    const activeTotalForTooltip = pieView === "http" ? httpPieData.totalCalls : parsedPieData.totalParsed;

    return (
    <div className={styles['stats-panel']}>
    {/* Header */}
    <div className={styles['stats-panel__top-header']}>
    <div className={styles['header-left']}>
    {appContext?.setPanel && (
    <BackButton
    onClick={() => appContext.setPanel!(0)}
    size="small"
    aria-label="Back to search home"
    >
    <ArrowBackIcon />
    </BackButton>
    )}
    <Typography variant="subtitle2">Supplier Stats</Typography>
    </div>
    <div className={styles['header-right']}>
    <Typography variant="caption" color="text.secondary">
    {totalCalls} call{totalCalls !== 1 ? "s" : ""}
    </Typography>
    {hasData && (
    <Tooltip title="Clear stats">
    <IconButton size="small" onClick={handleClear} className={styles['stats-panel__clear-btn']}>
    <DeleteIcon className={styles['stats-panel__clear-icon']} />
    </IconButton>
    </Tooltip>
    )}
    </div>
    </div>

    {!hasData ? (
    <Typography variant="body2" color="text.secondary" className={styles['stats-panel__empty']}>
    No stats yet. Run a search to start tracking.
    </Typography>
    ) : (
    <>
    <Tabs
    value={activeTab}
    onChange={(_e, v) => setActiveTab(v)}
    variant="fullWidth"
    className={styles['stats-panel__tabs']}
    >
    <Tab label="By Supplier" />
    <Tab label="Daily" />
    <Tab label="Totals" />
    </Tabs>

    <Paper className={styles['stats-panel__content']} elevation={2}>
    {/* Tab 0: Sunburst pie with toggle */}
    {activeTab === 0 && (
    <>
    <Box className={styles['stats-panel__toggle-container']}>
    <ToggleButtonGroup
    color="primary"
    size="small"
    value={pieView}
    exclusive
    onChange={(_e, v) => { if (v !== null) setPieView(v); }}
    >
    <ToggleButton value="http">HTTP Calls</ToggleButton>
    <ToggleButton value="parsed">Parsed Data</ToggleButton>
    </ToggleButtonGroup>
    </Box>
    <Box className={styles['stats-panel__chart-container']}>
    <PieChart
    series={[
    {
    innerRadius,
    outerRadius: middleRadius,
    data: activePie.inner.map((d) => ({ ...d, label: d.id })),
    valueFormatter: ({ value }) =>
    `${value} of ${activeTotalForTooltip} (${activeTotalForTooltip > 0 ? ((value / activeTotalForTooltip) * 100).toFixed(0) : 0}%)`,
    highlightScope: { fade: "global", highlight: "item" },
    highlighted: { additionalRadius: 2 },
    cornerRadius: 3,
    paddingAngle: 2,
    },
    {
    innerRadius: middleRadius,
    outerRadius: middleRadius + 20,
    data: activePie.outer,
    valueFormatter: (item) =>
    `${item.value} (${((item as any).percentage ?? 0).toFixed(0)}%)`,
    highlightScope: { fade: "global", highlight: "item" },
    highlighted: { additionalRadius: 2 },
    cornerRadius: 3,
    paddingAngle: 1,
    },
    ]}
    width={500}
    height={300}
    hideLegend
    >
    <PieCenterLabel>{centerLabel}</PieCenterLabel>
    </PieChart>
    </Box>
    {/* Custom legend for supplier colors only */}
    <Box className={styles['stats-panel__legend']}>
    {activePie.inner.map((d) => (
    <Box key={d.id} className={styles['stats-panel__legend-item']}>
    <Box className={styles['stats-panel__legend-dot']} style={{ backgroundColor: d.color }} />
    <span>{d.id}</span>
    </Box>
    ))}
    </Box>
    </>
    )}

    {/* Tab 1: Line chart — daily calls per supplier */}
    {activeTab === 1 && (
    <Box className={styles['stats-panel__chart-container']}>
    {lineDates.length > 0 && lineSeries.length > 0 && (
    <LineChart
    xAxis={[{ scaleType: "point", data: lineDates }]}
    series={lineSeries}
    width={500}
    height={320}
    hideLegend
    />
    )}
    {/* Custom legend */}
    <Box className={styles['stats-panel__legend']}>
    {lineSeries.map((s) => (
    <Box key={s.label} className={styles['stats-panel__legend-item']}>
    <Box className={styles['stats-panel__legend-dot']} style={{ backgroundColor: s.color }} />
    <span>{s.label}</span>
    </Box>
    ))}
    </Box>
    </Box>
    )}

    {/* Tab 2: Totals table */}
    {activeTab === 2 && (
    <div className={styles['stats-panel__table-container']}>
    <DataGrid
    rows={totalsRows}
    columns={totalsColumns}
    density="compact"
    disableColumnMenu
    hideFooter={totalsRows.length <= 25}
    initialState={{
    sorting: { sortModel: [{ field: "success", sort: "desc" }] },
    }}
    className={styles['stats-panel__table']}
    />
    </div>
    )}
    </Paper>
    </>
    )}
    </div>
    );
    };