Const<PriceHistoryPanel />
const PriceHistoryPanel: FC = () => {
const appContext = useAppContext();
const [rows, setRows] = useState<PriceChangeRow[]>([]);
const [sorting, setSorting] = useState<SortingState>([{ id: 'changedAt', desc: true }]);
useEffect(() => {
const loadRows = async () => {
try {
const entries = await getAllPriceSeries();
setRows(buildPriceChangeRows(entries));
} catch (error) {
console.warn('Failed to load price history', error);
}
};
void loadRows();
}, []);
const userSettings = appContext.userSettings;
const columns = useMemo<ColumnDef<PriceChangeRow, unknown>[]>(
() => [
{
id: 'changedAt',
header: i18n('price_history_panel_col_date'),
accessorFn: (row) => row.changedAt,
cell: ({ getValue }) => formatTimestamp(getValue<number>()),
size: 150,
},
{
id: 'supplier',
header: i18n('column_supplier'),
accessorKey: 'supplier',
size: 140,
},
{
id: 'title',
header: i18n('column_title'),
accessorKey: 'title',
cell: ({ row }) =>
row.original.permalink ? (
<Link href={row.original.permalink} target="_blank" rel="noopener noreferrer">
{row.original.title}
</Link>
) : (
row.original.title
),
size: 260,
},
{
id: 'oldPriceUsd',
header: i18n('price_history_panel_col_old_price'),
accessorFn: (row) => row.oldPriceUsd,
cell: ({ getValue }) => formatUsd(getValue<number>(), userSettings),
size: 110,
},
{
id: 'newPriceUsd',
header: i18n('price_history_panel_col_new_price'),
accessorFn: (row) => row.newPriceUsd,
cell: ({ getValue }) => formatUsd(getValue<number>(), userSettings),
size: 110,
},
{
id: 'change',
header: i18n('column_price_change'),
accessorFn: (row) => row.pctChange,
cell: ({ row }) => (
<PriceTrend
points={[
{ t: row.original.changedAt - 1, usd: row.original.oldPriceUsd },
{ t: row.original.changedAt, usd: row.original.newPriceUsd },
]}
userSettings={userSettings}
/>
),
size: 150,
},
],
[userSettings],
);
const table = useReactTable({
data: rows,
columns,
state: { sorting },
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize: 25 } },
// Required by the project-wide `SortingFns`/`FilterFns` augmentation (see
// `unusedFilterFn`); none of these ids are used by this panel's columns.
sortingFns: {
matchPercentage: matchPercentageSortingFn,
priceSortingFn,
puritySortingFn,
quantitySortingFn,
unitPriceSortingFn,
},
filterFns: {
multiSelect: unusedFilterFn,
includeHierarchy: unusedFilterFn,
inNumberRangeHierarchy: unusedFilterFn,
},
});
const totalRowCount = rows.length;
return (
<div className={styles['price-history-panel']}>
<div className={styles['price-history-panel__top-header']}>
<div className={styles['header-left']}>
{appContext.setPanel && (
<BackButton
onClick={() => appContext.setPanel!(PANEL.SEARCH_HOME)}
size="small"
aria-label={i18n('common_back_to_search')}
>
<ArrowBackIcon />
</BackButton>
)}
<Typography variant="subtitle2">{i18n('price_history_panel_title')}</Typography>
</div>
</div>
<div className={styles['price-history-panel__content']}>
<SearchResultsTable>
<StyledTableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<SortableTableHeaderCell
key={header.id}
canSort={header.column.getCanSort()}
cellWidth={header.getSize()}
onClick={header.column.getToggleSortingHandler()}
>
<HeaderCellContent>
{flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getCanSort() && (
<SortIndicator>
{
{
asc: <ArrowDropUpIcon />,
desc: <ArrowDropDownIcon />,
}[String(header.column.getIsSorted())]
}
</SortIndicator>
)}
</HeaderCellContent>
</SortableTableHeaderCell>
))}
</TableRow>
))}
</StyledTableHead>
<StyledTableBody>
{table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => (
<StyledTableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<StyledTableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</StyledTableCell>
))}
</StyledTableRow>
))
) : (
<TableRow>
<EmptyStateCell colSpan={columns.length}>
{i18n('price_history_panel_empty')}
</EmptyStateCell>
</TableRow>
)}
</StyledTableBody>
</SearchResultsTable>
{totalRowCount > 10 && (
<PaginationContainer>
<PageSizeContainer>
<Typography variant="body2">{i18n('results_show')}:</Typography>
<FormControl size="small">
<PageSizeSelect
value={table.getState().pagination.pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
aria-label={i18n('results_rows_per_page_aria')}
>
{generatePageSizes(totalRowCount, 10, 5).map((pageSize) => (
<MenuItem key={pageSize} value={pageSize}>
{pageSize === totalRowCount ? i18n('results_all') : pageSize}
</MenuItem>
))}
</PageSizeSelect>
</FormControl>
<Typography variant="body2">{i18n('results_rows')}</Typography>
</PageSizeContainer>
<Typography variant="body2">
{i18n('results_page_of_total', [
String(table.getState().pagination.pageIndex + 1),
String(table.getPageCount()),
])}{' '}
{i18n('results_total', [String(totalRowCount)])}
</Typography>
<NavigationContainer>
<IconButton
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
size="small"
>
<FirstPageIcon />
</IconButton>
<IconButton
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
size="small"
>
<ChevronLeftIcon />
</IconButton>
<IconButton
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
size="small"
>
<ChevronRightIcon />
</IconButton>
<IconButton
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()}
size="small"
>
<LastPageIcon />
</IconButton>
</NavigationContainer>
</PaginationContainer>
)}
</div>
</div>
);
};
Full-panel, sortable log of every price change ChemPal has ever recorded across every supplier and product — one row per change, newest first by default. Only available in advanced mode; wired up the same way as
StatsPanel: aPANEL.PRICE_HISTORYentry in the app's panel switch, reached from the speed-dial menu, hidden the moment advanced mode turns off.Reads the full
price_historyIndexedDB store via getAllPriceSeries and flattens it withbuildPriceChangeRows— series with only one recorded price (never changed) contribute no rows, so this view answers "which products actually do have price changes logged".