diff --git a/web/src/screens/LibrariesScreen.test.tsx b/web/src/screens/LibrariesScreen.test.tsx index df72f8f39..e68f644e0 100644 --- a/web/src/screens/LibrariesScreen.test.tsx +++ b/web/src/screens/LibrariesScreen.test.tsx @@ -186,6 +186,29 @@ describe('LibrariesScreen', () => { expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object)); }); + it('renders "Never scanned" for a DateTime.MinValue-seeded lastScan, not a bogus midnight time (#409)', async () => { + mockApi({ + mediaSources: [ + mediaSource({ + libraries: [ + library({ id: 31, itemCount: 0, lastScan: '0001-01-01T00:00:00', mediaKind: 'Movies', name: 'Movies' }), + library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' }) + ] + }) + ] + }); + + render(); + + expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0); + // Both the never-scanned-MinValue library row and the never-scanned-null library row read + // "Never scanned" - a plain `Boolean(lastScan)` truthiness check would treat the MinValue string + // as a real scan and print "Last scan 12:00 AM" instead. + expect(screen.getAllByText('Never scanned').length).toBeGreaterThanOrEqual(2); + expect(screen.queryByText(/Last scan/)).not.toBeInTheDocument(); + expect(screen.queryByText(/12:00 AM/)).not.toBeInTheDocument(); + }); + it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => { mockApi({ libraryScanStatuses: [ @@ -255,8 +278,18 @@ describe('LibrariesScreen', () => { const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources'); await runPollTick(intervalHandlers); - expect(screen.queryByText('75%')).not.toBeInTheDocument(); - expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); + // loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain + // (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the + // state update that clears the progress bar and the follow-up loadSources() refetch both land a + // few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved. + // Asserting synchronously right after the tick races that chain (#447); waitFor lets both + // settle before asserting, without weakening either check. + await waitFor(() => { + expect(screen.queryByText('75%')).not.toBeInTheDocument(); + }); + await waitFor(() => { + expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1); + }); }); it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => { diff --git a/web/src/screens/LibrariesScreen.tsx b/web/src/screens/LibrariesScreen.tsx index cba971637..534c8bc35 100644 --- a/web/src/screens/LibrariesScreen.tsx +++ b/web/src/screens/LibrariesScreen.tsx @@ -394,7 +394,7 @@ function LibraryRow({
{library.name} {formatLibraryMediaKind(library.mediaKind)} ยท {library.itemCount.toLocaleString()} items - {library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'} + {hasScanned(library.lastScan) ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}
{scanStatus ? ( @@ -465,12 +465,33 @@ function formatLibraryMediaKind(kind: string): string { function sourceLastScanLabel(source: MediaSource): string { const scans = source.libraries .map((library) => library.lastScan) - .filter((scan): scan is string => Boolean(scan)) + .filter((scan): scan is string => hasScanned(scan)) .sort(); return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned'; } +// A never-scanned local library's LastScan is seeded as DateTime.MinValue (not NULL), which the API +// serialises as the truthy string "0001-01-01T00:00:00" - a plain `Boolean(scan)` truthiness check +// (the previous behavior here) treats that as a real scan and renders a bogus midnight time (#409). +// Detect the MinValue sentinel client-side alongside null/undefined so both render "Never scanned". +// (Server-side normalisation of the DTO was considered - see #409 - but the SPA already owns every +// consumer of this field, so a read-boundary fix here covers both call sites without touching the API.) +function hasScanned(scan: string | null | undefined): scan is string { + if (!scan) { + return false; + } + + const parsed = new Date(scan); + if (Number.isNaN(parsed.getTime())) { + return false; + } + + // DateTime.MinValue is year 0001; treat anything before year 1900 as "no real scan" rather than + // matching the exact sentinel string, so any equivalent epoch-ish placeholder is also caught. + return parsed.getUTCFullYear() >= 1900; +} + // Inlined rather than imported back from App.tsx (mirrors the ChannelsScreen #244 extraction): // App.tsx's formatDateTime is still used by other App.tsx-owned screens (Playouts, Guide), so // keeping this screen self-sufficient means duplicating the tiny formatter here.