fix(archives): sort and total on real archive sizes, escape LIKE wildcards
Closes the three trade-offs the server-side archives query deliberately
accepted.
C1 -- the sorted number and the displayed number are now the same one.
There was no archive_size column, so the Size column came from a per-row
fs.stat done after pagination while the sort fell back to summed photo bytes:
the list could be ordered by a number the user was not looking at. Adds
events.archive_size (bigInteger -- int4's 2.1GB ceiling is the same limit
that forced the restore path off adm-zip), written at archive time from
archive.pointer(), which is the exact byte count the completion email already
reports. The route now sorts and displays that column and no longer touches
the filesystem. The migration backfills by stat-ing every archive_path where
the column is null, outside the column guard so a half-finished run
self-heals; unstatable rows (missing zip, S3-backed storage) stay null, order
last via COALESCE and display 0 -- exactly what the old fs.stat produced for
a file it could not read. Restore nulls it alongside archive_path.
Accepted: the list no longer notices a zip deleted out of band and shows the
last recorded size. The detail route still stats the real file.
C2 -- escape \ % _ in the bound value plus an explicit ESCAPE '\'. The
ESCAPE clause is load-bearing rather than decorative: SQLite has no default
LIKE escape character, so without it the escaped pattern matches literal
backslashes and the search silently returns nothing on SQLite while working
on Postgres. The value stays bound; no interpolation.
C3 -- the four stat cards aggregated only the current page, so every total
was wrong for any dataset past page one. The list response now carries
totals { archives, photos, archiveSize } computed with the same applyFilters()
closure as pagination.total, so cards and footer cannot drift. Two aggregate
queries: archive_size sums on the unjoined events query (joining photos
multiplies it by photo count) and photos count on the joined one, both read
back through Number() for pg's bigint-as-string. The "Showing X of Y" line
moved out of the totalPages > 1 guard so it survives a single-page result,
now gated on total > 0 so a zero-result search does not render
"Showing 1 to 0 of 0"; only the page controls stay conditional.
Test fixtures deliberately order zip sizes differently from summed photo
bytes, so the sort test can only pass on the right column.
Refs testplan REPORT.md C1, C2, C3.
This commit is contained in:
@@ -68,9 +68,11 @@ export const ArchivesPage: React.FC = () => {
|
||||
|
||||
const archives = archivesData?.archives || [];
|
||||
|
||||
const getTotalSize = () => {
|
||||
return archives.reduce((sum, archive) => sum + archive.archiveSize, 0);
|
||||
};
|
||||
// Server-side aggregates over the whole filtered set. Summing `archives`
|
||||
// here only ever described the 20 rows of the current page, so "Storage
|
||||
// used" on an 802-archive install was off by roughly 40x while the footer
|
||||
// right below it reported the real total.
|
||||
const totals = archivesData?.totals ?? { archives: 0, photos: 0, archiveSize: 0 };
|
||||
|
||||
// Mutations
|
||||
const restoreMutation = useMutationWithToast({
|
||||
@@ -136,7 +138,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalArchives')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archives.length}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{totals.archives}</p>
|
||||
</div>
|
||||
<Archive className="w-8 h-8 text-accent" />
|
||||
</div>
|
||||
@@ -146,7 +148,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.storageUsed')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archiveService.formatBytes(getTotalSize())}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{archiveService.formatBytes(totals.archiveSize)}</p>
|
||||
</div>
|
||||
<HardDrive className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
@@ -157,10 +159,7 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.totalPhotos')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{(() => {
|
||||
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
|
||||
return total === 0 ? '0' : total.toLocaleString();
|
||||
})()}
|
||||
{totals.photos === 0 ? '0' : totals.photos.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<FileArchive className="w-8 h-8 text-green-600" />
|
||||
@@ -172,8 +171,8 @@ export const ArchivesPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('archives.avgArchiveSize')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{archives.length > 0
|
||||
? archiveService.formatBytes(getTotalSize() / archives.length)
|
||||
{totals.archives > 0
|
||||
? archiveService.formatBytes(totals.archiveSize / totals.archives)
|
||||
: '0 Bytes'
|
||||
}
|
||||
</p>
|
||||
@@ -340,39 +339,45 @@ export const ArchivesPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
{archivesData?.pagination && archivesData.pagination.totalPages > 1 && (
|
||||
{/* Pagination. The count is shown for any non-empty result — it used to
|
||||
be inside the totalPages > 1 guard, so a search that narrowed to a
|
||||
single page lost the "Showing X of Y" line along with the controls,
|
||||
which is exactly when the count is worth reading. Only the page
|
||||
controls are conditional now. */}
|
||||
{archivesData?.pagination && archivesData.pagination.total > 0 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-neutral-600">
|
||||
{t('archives.showing', {
|
||||
{t('archives.showing', {
|
||||
from: ((currentPage - 1) * archivesData.pagination.limit) + 1,
|
||||
to: Math.min(currentPage * archivesData.pagination.limit, archivesData.pagination.total),
|
||||
total: archivesData.pagination.total
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
{t('common.previous')}
|
||||
</Button>
|
||||
<span className="px-3 text-sm">
|
||||
{t('archives.page', { current: currentPage, total: archivesData.pagination.totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
|
||||
disabled={currentPage === archivesData.pagination.totalPages}
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
{archivesData.pagination.totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
>
|
||||
{t('common.previous')}
|
||||
</Button>
|
||||
<span className="px-3 text-sm">
|
||||
{t('archives.page', { current: currentPage, total: archivesData.pagination.totalPages })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(archivesData.pagination.totalPages, prev + 1))}
|
||||
disabled={currentPage === archivesData.pagination.totalPages}
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('common.next')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -55,9 +55,14 @@ vi.mock('../../../services/archive.service', () => ({
|
||||
|
||||
import { ArchivesPage } from '../ArchivesPage';
|
||||
|
||||
const page = (archives: unknown[], total: number) => ({
|
||||
type Totals = { archives: number; photos: number; archiveSize: number };
|
||||
|
||||
const page = (archives: unknown[], total: number, totals?: Totals) => ({
|
||||
archives,
|
||||
pagination: { page: 1, limit: 20, total, totalPages: Math.ceil(total / 20) },
|
||||
// The aggregates are computed server-side over the filtered set; the page
|
||||
// only renders them.
|
||||
totals: totals ?? { archives: total, photos: total * 3, archiveSize: total * 100 },
|
||||
});
|
||||
|
||||
const archive = (id: number, eventName: string, eventType = 'wedding') => ({
|
||||
@@ -133,4 +138,39 @@ describe('ArchivesPage server-side query (QA I.01)', () => {
|
||||
await userEvent.selectOptions(screen.getByDisplayValue('archives.allTypes'), 'corporate');
|
||||
await waitFor(() => expect(getArchives).toHaveBeenLastCalledWith(1, 20, undefined, 'corporate', 'date'));
|
||||
});
|
||||
|
||||
it('reads the stat cards from the server totals, not from the loaded page', async () => {
|
||||
// One row on screen, 802 in the dataset. Summing the rendered rows — what
|
||||
// the cards used to do — would report 1 archive and 100 bytes.
|
||||
getArchives.mockResolvedValue(
|
||||
page([archive(1, 'Alpha Wedding')], 802, { archives: 802, photos: 12345, archiveSize: 999000 })
|
||||
);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('802')).toBeInTheDocument();
|
||||
// Grouped by the runtime's locale, so ask it rather than hardcoding.
|
||||
expect(screen.getByText((12345).toLocaleString())).toBeInTheDocument();
|
||||
expect(screen.getByText('999000 B')).toBeInTheDocument();
|
||||
// Average is over the whole set too, not over the page.
|
||||
expect(screen.getByText(`${999000 / 802} B`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the "showing X of Y" count on a single-page result', async () => {
|
||||
// The count used to live inside the `totalPages > 1` guard, so a search
|
||||
// that narrowed to one page hid the number that says how many matched.
|
||||
getArchives.mockResolvedValue(page([archive(1, 'Alpha Wedding')], 1));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('archives.showing')).toBeInTheDocument();
|
||||
// Page controls stay hidden — there is only one page.
|
||||
expect(screen.queryByText('common.next')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no count at all when nothing matched', async () => {
|
||||
getArchives.mockResolvedValue(page([], 0));
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('archives.noArchivesFound')).toBeInTheDocument();
|
||||
expect(screen.queryByText('archives.showing')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,13 @@ export interface ArchivesResponse {
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
// Aggregates over the whole filtered set, not the page — the stat cards
|
||||
// summed the loaded rows, so they described 20 archives out of 802.
|
||||
totals?: {
|
||||
archives: number;
|
||||
photos: number;
|
||||
archiveSize: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const archiveService = {
|
||||
|
||||
Reference in New Issue
Block a user