From a23b01a7149df7c466b79ba3532a605fec540ed3 Mon Sep 17 00:00:00 2001 From: kavryn <1200215+kavryn@users.noreply.github.com> Date: Wed, 10 Jun 2026 02:43:48 +0300 Subject: [PATCH 1/4] feat: add sources page listing archive items --- src/app/components/header.tsx | 12 ++ src/app/sources/page.module.css | 80 ++++++++++ src/app/sources/page.spec.tsx | 164 +++++++++++++++++++++ src/app/sources/page.tsx | 114 ++++++++++++++ src/app/sources/sources-filter.module.css | 48 ++++++ src/app/sources/sources-filter.test.tsx | 145 ++++++++++++++++++ src/app/sources/sources-filter.tsx | 160 ++++++++++++++++++++ src/shared/get-archive-sources.test.ts | 131 ++++++++++++++++ src/shared/get-archive-sources.ts | 80 ++++++++++ src/shared/parse-archive-reference.test.ts | 81 ++++++++++ src/shared/parse-archive-reference.ts | 52 +++++++ 11 files changed, 1067 insertions(+) create mode 100644 src/app/sources/page.module.css create mode 100644 src/app/sources/page.spec.tsx create mode 100644 src/app/sources/page.tsx create mode 100644 src/app/sources/sources-filter.module.css create mode 100644 src/app/sources/sources-filter.test.tsx create mode 100644 src/app/sources/sources-filter.tsx create mode 100644 src/shared/get-archive-sources.test.ts create mode 100644 src/shared/get-archive-sources.ts create mode 100644 src/shared/parse-archive-reference.test.ts create mode 100644 src/shared/parse-archive-reference.ts diff --git a/src/app/components/header.tsx b/src/app/components/header.tsx index b125738d..98396c6b 100644 --- a/src/app/components/header.tsx +++ b/src/app/components/header.tsx @@ -94,6 +94,18 @@ export default function Header() { Таблиці +
  • + + Джерела + +
  • diff --git a/src/app/sources/page.module.css b/src/app/sources/page.module.css new file mode 100644 index 00000000..cb63239b --- /dev/null +++ b/src/app/sources/page.module.css @@ -0,0 +1,80 @@ +.container { + padding: var(--side-gap) 0; +} + +.subtitle { + color: var(--description-color); + margin: 0.5rem 0 1rem; +} + +.tableWrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.95rem; +} + +.table th, +.table td { + padding: 6px 10px; + border-bottom: var(--default-border); + text-align: left; + vertical-align: top; +} + +.table th { + background: var(--table-row-header-bg); + color: var(--table-row-header-text); + position: sticky; + top: 0; + z-index: 1; +} + +.table th.centered { + text-align: center; +} + +.table tbody tr:nth-child(even) { + background: var(--table-row-even-bg); +} + +.table tbody tr:hover { + background: var(--table-row-hover-bg); +} + +.compact { + width: 1%; + white-space: nowrap; +} + +.years { + width: 1%; + white-space: nowrap; + font-variant-numeric: tabular-nums; + text-align: center; +} + +.raw { + color: var(--description-color); + font-style: italic; +} + +.tablesCell { + max-width: 36ch; +} + +.tablesCell a { + display: block; +} + +.tablesCell a + a { + margin-top: 4px; +} + +.empty { + padding: 1rem 0; + color: var(--description-color); +} diff --git a/src/app/sources/page.spec.tsx b/src/app/sources/page.spec.tsx new file mode 100644 index 00000000..02693c55 --- /dev/null +++ b/src/app/sources/page.spec.tsx @@ -0,0 +1,164 @@ +import { cleanup, render, screen, within } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import getArchiveSources, { + type ArchiveSource, +} from '@/shared/get-archive-sources'; + +import SourcesPage from './page'; + +vi.mock('@/shared/get-archive-sources', () => ({ + default: vi.fn(), +})); + +vi.mock('next/link', () => ({ + default: ({ children, href }: { children: ReactNode; href: string }) => ( + {children} + ), +})); + +vi.mock('./page.module.css', () => ({ + default: new Proxy({}, { get: (_t, p) => String(p) }), +})); + +vi.mock('./sources-filter.module.css', () => ({ + default: new Proxy({}, { get: (_t, p) => String(p) }), +})); + +const buildSource = ( + overrides: Partial = {}, +): ArchiveSource => ({ + archive: 'ДАКО', + fond: '384', + opys: '10', + sprava: '242', + raw: 'ДАКО-384-10-242', + key: 'ДАКО|384|10|242', + tables: [{ id: 't1', title: 'Table 1' }], + yearsRange: [1900], + ...overrides, +}); + +describe('SourcesPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('renders one row per source with parsed fields and table links', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource({ + tables: [ + { id: 't1', title: 'Table 1' }, + { id: 't2', title: 'Table 2' }, + ], + yearsRange: [1900, 1910], + }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + const row = screen.getByText('242').closest('tr') as HTMLTableRowElement; + const cells = within(row).getAllByRole('cell'); + expect(cells[0].textContent).toBe('ДАКО'); + expect(cells[1].textContent).toBe('384'); + expect(cells[2].textContent).toBe('10'); + expect(cells[3].textContent).toBe('242'); + expect(cells[4].textContent).toBe('1900–1910'); + + const link1 = within(row).getByRole('link', { name: 'Table 1' }); + expect(link1.getAttribute('href')).toBe('/t1/1/'); + expect(within(row).getByRole('link', { name: 'Table 2' })).toBeDefined(); + }); + + it('collapses tables into
    when there are 5 or more', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource({ + tables: Array.from({ length: 6 }, (_, index) => ({ + id: `t${index}`, + title: `Table ${index}`, + })), + }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + expect(screen.getByText('6 таблиць')).toBeDefined(); + }); + + it('renders unparsed sources with raw text spanning the code columns', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource({ + archive: '', + fond: '', + opys: '', + sprava: '', + raw: 'AGAD 298/151', + key: 'AGAD 298/151', + }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + const rawCell = screen.getByText('AGAD 298/151'); + expect(rawCell.tagName).toBe('TD'); + expect(rawCell.getAttribute('colspan')).toBe('4'); + }); + + it('renders "Інші джерела" option in the archive select when unparsed sources exist', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource(), + buildSource({ + archive: '', + fond: '', + opys: '', + sprava: '', + raw: 'AGAD 298/151', + key: 'AGAD 298/151', + }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + const select = screen.getByLabelText('Архів'); + expect( + within(select).getByRole('option', { name: 'Інші джерела' }), + ).toBeDefined(); + }); + + it('omits "Інші джерела" option when all sources are parsed', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([buildSource()]); + + const jsx = await SourcesPage(); + render(jsx); + + expect(screen.queryByText('Інші джерела')).toBeNull(); + }); + + it('lists each unique archive once in the select', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource({ archive: 'ДАКО', key: 'a' }), + buildSource({ archive: 'ДАКО', key: 'b' }), + buildSource({ archive: 'ДАЖО', key: 'c' }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + const select = screen.getByLabelText('Архів'); + expect( + within(select).getAllByRole('option', { name: 'ДАКО' }), + ).toHaveLength(1); + expect( + within(select).getAllByRole('option', { name: 'ДАЖО' }), + ).toHaveLength(1); + }); +}); diff --git a/src/app/sources/page.tsx b/src/app/sources/page.tsx new file mode 100644 index 00000000..9249d2be --- /dev/null +++ b/src/app/sources/page.tsx @@ -0,0 +1,114 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import getArchiveSources from '@/shared/get-archive-sources'; + +import SourcesFilter from './sources-filter'; + +import styles from './page.module.css'; + +export const metadata: Metadata = { + title: 'Джерела', + description: 'Перелік архівних справ на Коренях.', + alternates: { + canonical: '/sources/', + }, + openGraph: { + title: 'Джерела', + description: 'Перелік архівних справ на Коренях.', + url: '/sources/', + }, + twitter: { + title: 'Джерела', + description: 'Перелік архівних справ на Коренях.', + }, +}; + +function formatYears(range: readonly number[]): string { + if (range.length === 1) return String(range[0]); + return `${range[0]}–${range[1]}`; +} + +export default async function SourcesPage() { + const sources = await getArchiveSources(); + const archives = [ + ...new Set(sources.map((s) => s.archive).filter(Boolean)), + ].sort((a, b) => a.localeCompare(b, 'uk')); + const hasOther = sources.some((s) => !s.archive); + + return ( +
    +

    Джерела

    +

    + Архівні справи, з яких походять записи в таблицях Коренів. Перевірте, чи + проіндексована потрібна вам справа. +

    + + +
    + + + + + + + + + + + + + {sources.map((source) => ( + + {source.archive ? ( + <> + + + + + + ) : ( + + )} + + + + ))} + +
    АрхівФондОписСправаРокиТаблиці
    {source.archive}{source.fond}{source.opys}{source.sprava || '—'} + {source.raw} + + {formatYears(source.yearsRange)} + + {source.tables.length >= 5 ? ( +
    + {source.tables.length} таблиць + {source.tables.map((t) => ( + + {t.title} + + ))} +
    + ) : ( + source.tables.map((t) => ( + + {t.title} + + )) + )} +
    +
    +
    +
    + ); +} diff --git a/src/app/sources/sources-filter.module.css b/src/app/sources/sources-filter.module.css new file mode 100644 index 00000000..572fa721 --- /dev/null +++ b/src/app/sources/sources-filter.module.css @@ -0,0 +1,48 @@ +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 8px; + margin-bottom: 12px; + align-items: end; +} + +.field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.field label { + font-size: 0.8rem; + color: var(--description-color); +} + +.field input, +.field select { + padding: 6px 8px; + border: var(--default-border); + border-radius: 4px; + background: var(--bg-color); + color: var(--text-color); + font: inherit; +} + +.summary { + color: var(--description-color); + font-size: 0.9rem; + margin-bottom: 8px; +} + +.reset { + padding: 6px 10px; + border: var(--default-border); + background: var(--bg-color); + color: var(--text-color); + border-radius: 4px; + cursor: pointer; + font: inherit; +} + +.reset:hover { + background: var(--table-row-hover-bg); +} diff --git a/src/app/sources/sources-filter.test.tsx b/src/app/sources/sources-filter.test.tsx new file mode 100644 index 00000000..b6a1e651 --- /dev/null +++ b/src/app/sources/sources-filter.test.tsx @@ -0,0 +1,145 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import SourcesFilter from './sources-filter'; + +function renderFilter({ + archives = ['ДАКО', 'ДАЖО'], + hasOther = true, +}: { archives?: string[]; hasOther?: boolean } = {}) { + return render( + + + + + + + + + + + + + +
    row-a
    row-b
    row-c
    +
    , + ); +} + +const getRow = (label: string) => + screen.getByText(label).closest('tr') as HTMLTableRowElement; + +const waitForDisplays = ( + rowA: string, + rowB: string, + rowC: string, +): Promise => + waitFor(() => { + const triple = [ + getRow('row-a').style.display, + getRow('row-b').style.display, + getRow('row-c').style.display, + ] as const; + if (triple[0] !== rowA || triple[1] !== rowB || triple[2] !== rowC) { + throw new Error( + `expected [${rowA}, ${rowB}, ${rowC}] got [${triple.join(', ')}]`, + ); + } + return triple; + }); + +describe('SourcesFilter', () => { + afterEach(() => { + cleanup(); + }); + + it('shows all rows initially', async () => { + renderFilter(); + expect(await waitForDisplays('', '', '')).toStrictEqual(['', '', '']); + }); + + it('filters by archive', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Архів'), { + target: { value: 'ДАКО' }, + }); + expect(await waitForDisplays('', 'none', 'none')).toStrictEqual([ + '', + 'none', + 'none', + ]); + }); + + it('filters by fond prefix', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Фонд'), { + target: { value: '38' }, + }); + expect(await waitForDisplays('', 'none', 'none')).toStrictEqual([ + '', + 'none', + 'none', + ]); + }); + + it('shows only unparsed rows when "Інші джерела" is selected', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Архів'), { + target: { value: '__other__' }, + }); + expect(await waitForDisplays('none', 'none', '')).toStrictEqual([ + 'none', + 'none', + '', + ]); + }); + + it('disables fond/opys/sprava when "Інші джерела" is selected', () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Архів'), { + target: { value: '__other__' }, + }); + expect(screen.getByLabelText('Фонд')).toBeDisabled(); + expect(screen.getByLabelText('Опис')).toBeDisabled(); + expect(screen.getByLabelText('Справа')).toBeDisabled(); + }); + + it('clears subfield values when switching to "Інші джерела"', () => { + renderFilter(); + const fond = screen.getByLabelText('Фонд'); + fireEvent.change(fond, { target: { value: '384' } }); + expect(fond.value).toBe('384'); + fireEvent.change(screen.getByLabelText('Архів'), { + target: { value: '__other__' }, + }); + expect(fond.value).toBe(''); + }); + + it('reset clears every filter and restores all rows', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Архів'), { + target: { value: 'ДАКО' }, + }); + fireEvent.change(screen.getByLabelText('Фонд'), { + target: { value: '999' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Скинути' })); + expect(await waitForDisplays('', '', '')).toStrictEqual(['', '', '']); + }); + + it('hides "Інші джерела" option when hasOther is false', () => { + renderFilter({ hasOther: false }); + expect(screen.queryByText('Інші джерела')).toBeNull(); + }); +}); diff --git a/src/app/sources/sources-filter.tsx b/src/app/sources/sources-filter.tsx new file mode 100644 index 00000000..d877fb25 --- /dev/null +++ b/src/app/sources/sources-filter.tsx @@ -0,0 +1,160 @@ +'use client'; + +import { + type ReactNode, + useDeferredValue, + useEffect, + useRef, + useState, +} from 'react'; + +import styles from './sources-filter.module.css'; + +type Properties = { + archives: string[]; + hasOther: boolean; + totalCount: number; + children: ReactNode; +}; + +const OTHER = '__other__'; + +const startsWithLower = (value: string | undefined, query: string): boolean => { + if (!query) return true; + if (!value) return false; + return value.toLowerCase().startsWith(query.toLowerCase()); +}; + +export default function SourcesFilter({ + archives, + hasOther, + totalCount, + children, +}: Properties) { + const containerReference = useRef(null); + const [archive, setArchive] = useState(''); + const [fond, setFond] = useState(''); + const [opys, setOpys] = useState(''); + const [sprava, setSprava] = useState(''); + const [visibleCount, setVisibleCount] = useState(totalCount); + + const deferredArchive = useDeferredValue(archive); + const deferredFond = useDeferredValue(fond); + const deferredOpys = useDeferredValue(opys); + const deferredSprava = useDeferredValue(sprava); + + useEffect(() => { + const container = containerReference.current; + if (!container) return; + const rows = container.querySelectorAll('tr[data-archive]'); + const fondQuery = deferredFond.trim(); + const opysQuery = deferredOpys.trim(); + const spravaQuery = deferredSprava.trim(); + let visible = 0; + for (const row of rows) { + const rowArchive = row.dataset.archive ?? ''; + const archiveMatches = + !deferredArchive || + (deferredArchive === OTHER + ? rowArchive === '' + : rowArchive === deferredArchive); + const matches = + archiveMatches && + startsWithLower(row.dataset.fond, fondQuery) && + startsWithLower(row.dataset.opys, opysQuery) && + startsWithLower(row.dataset.sprava, spravaQuery); + const nextDisplay = matches ? '' : 'none'; + if (row.style.display !== nextDisplay) { + row.style.display = nextDisplay; + } + if (matches) visible += 1; + } + setVisibleCount(visible); + }, [deferredArchive, deferredFond, deferredOpys, deferredSprava]); + + const subfieldsDisabled = archive === OTHER; + + const reset = () => { + setArchive(''); + setFond(''); + setOpys(''); + setSprava(''); + }; + + return ( +
    +
    +
    + + +
    +
    + + { + setFond(event.target.value); + }} + placeholder="напр. 384" + disabled={subfieldsDisabled} + /> +
    +
    + + { + setOpys(event.target.value); + }} + disabled={subfieldsDisabled} + /> +
    +
    + + { + setSprava(event.target.value); + }} + disabled={subfieldsDisabled} + /> +
    +
    + +
    +
    +
    + Показано {visibleCount.toLocaleString('uk-UA')} з{' '} + {totalCount.toLocaleString('uk-UA')} +
    + {children} +
    + ); +} diff --git a/src/shared/get-archive-sources.test.ts b/src/shared/get-archive-sources.test.ts new file mode 100644 index 00000000..c3b56119 --- /dev/null +++ b/src/shared/get-archive-sources.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { IndexationTable } from './schemas/indexation-table'; +import getArchiveSources from './get-archive-sources'; +import getTablesMetadata from './get-tables-metadata'; + +vi.mock('./get-tables-metadata'); + +function buildTable(overrides: Partial): IndexationTable { + return { + archiveItems: ['ДАКО-1-1-1'], + authorName: 'Author', + date: new Date('2025-01-01'), + id: 't', + location: [50, 30], + size: 100, + sources: ['https://example.com'], + tableFilePath: 'data/records/t.csv', + tableLocale: 'uk', + title: 'Table', + yearsRange: [1900], + ...overrides, + }; +} + +describe('getArchiveSources', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns one source per unique archive item with referencing table', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ + id: 't1', + title: 'Table 1', + archiveItems: ['ДАКО-384-10-242'], + }), + ]); + + const result = await getArchiveSources(); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + archive: 'ДАКО', + fond: '384', + opys: '10', + sprava: '242', + tables: [{ id: 't1', title: 'Table 1' }], + }); + }); + + it('merges archive items whose raw forms normalize to the same key', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ + id: 't1', + title: 'Table 1', + archiveItems: ['ДАВіО-Р6023-1-'], + }), + buildTable({ + id: 't2', + title: 'Table 2', + archiveItems: ['ДАВіО-Р-6023-1-'], + }), + ]); + + const result = await getArchiveSources(); + + expect(result).toHaveLength(1); + expect(result[0].tables.map((t) => t.id)).toStrictEqual(['t1', 't2']); + }); + + it('deduplicates the same table appearing twice for the same source', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ + id: 't1', + title: 'Table 1', + archiveItems: ['ДАКО-1-1-1', 'ДАКО-1-1-1'], + }), + ]); + + const result = await getArchiveSources(); + + expect(result[0].tables).toStrictEqual([{ id: 't1', title: 'Table 1' }]); + }); + + it('merges yearsRange across tables sharing a source', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ + id: 't1', + archiveItems: ['ДАКО-1-1-1'], + yearsRange: [1900], + }), + buildTable({ + id: 't2', + archiveItems: ['ДАКО-1-1-1'], + yearsRange: [1910, 1920], + }), + ]); + + const result = await getArchiveSources(); + + expect(result[0].yearsRange).toStrictEqual([1900, 1920]); + }); + + it('sorts by archive, then numerically by fond, opys, sprava', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ id: 'a', archiveItems: ['ДАКО-1-1-100'] }), + buildTable({ id: 'b', archiveItems: ['ДАКО-1-1-9'] }), + buildTable({ id: 'c', archiveItems: ['ДАЖО-1-1-1'] }), + ]); + + const result = await getArchiveSources(); + + expect(result.map((s) => s.raw)).toStrictEqual([ + 'ДАЖО-1-1-1', + 'ДАКО-1-1-9', + 'ДАКО-1-1-100', + ]); + }); + + it('places unparsed sources after parsed ones', async () => { + vi.mocked(getTablesMetadata).mockResolvedValue([ + buildTable({ id: 'a', archiveItems: ['AGAD 298/151'] }), + buildTable({ id: 'b', archiveItems: ['ДАКО-1-1-1'] }), + ]); + + const result = await getArchiveSources(); + + expect(result.map((s) => s.archive)).toStrictEqual(['ДАКО', '']); + }); +}); diff --git a/src/shared/get-archive-sources.ts b/src/shared/get-archive-sources.ts new file mode 100644 index 00000000..a558da45 --- /dev/null +++ b/src/shared/get-archive-sources.ts @@ -0,0 +1,80 @@ +import getTablesMetadata from './get-tables-metadata'; +import parseArchiveReference, { + type ArchiveReference, +} from './parse-archive-reference'; + +export type ArchiveSourceTable = { + id: string; + title: string; +}; + +export type ArchiveSource = ArchiveReference & { + key: string; + tables: ArchiveSourceTable[]; + yearsRange: [number] | [number, number]; +}; + +function compareNumericThenString(a: string, b: string): number { + const an = Number.parseInt(a, 10); + const bn = Number.parseInt(b, 10); + if (Number.isFinite(an) && Number.isFinite(bn) && an !== bn) return an - bn; + return a.localeCompare(b, 'uk'); +} + +function compareSources(a: ArchiveSource, b: ArchiveSource): number { + if (a.archive !== b.archive) { + if (!a.archive) return 1; + if (!b.archive) return -1; + return a.archive.localeCompare(b.archive, 'uk'); + } + const byFond = compareNumericThenString(a.fond, b.fond); + if (byFond !== 0) return byFond; + const byOpys = compareNumericThenString(a.opys, b.opys); + if (byOpys !== 0) return byOpys; + return compareNumericThenString(a.sprava, b.sprava); +} + +function mergeYearsRange( + current: ArchiveSource['yearsRange'], + next: readonly number[], +): ArchiveSource['yearsRange'] { + const min = Math.min(current[0], ...next); + const max = Math.max(current.at(-1) ?? current[0], ...next); + return min === max ? [min] : [min, max]; +} + +export default async function getArchiveSources(): Promise { + const tables = await getTablesMetadata(); + const map = new Map(); + + for (const table of tables) { + for (const rawItem of table.archiveItems) { + const parsed = parseArchiveReference(rawItem); + const key = parsed.archive + ? `${parsed.archive}|${parsed.fond}|${parsed.opys}|${parsed.sprava}` + : parsed.raw; + const existing = map.get(key); + if (existing) { + if (!existing.tables.some((t) => t.id === table.id)) { + existing.tables.push({ id: table.id, title: table.title }); + } + existing.yearsRange = mergeYearsRange( + existing.yearsRange, + table.yearsRange, + ); + } else { + map.set(key, { + ...parsed, + key, + tables: [{ id: table.id, title: table.title }], + yearsRange: + table.yearsRange.length === 1 + ? [table.yearsRange[0]] + : [table.yearsRange[0], table.yearsRange[1]], + }); + } + } + } + + return [...map.values()].sort(compareSources); +} diff --git a/src/shared/parse-archive-reference.test.ts b/src/shared/parse-archive-reference.test.ts new file mode 100644 index 00000000..06ab9e19 --- /dev/null +++ b/src/shared/parse-archive-reference.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; + +import parseArchiveReference from './parse-archive-reference'; + +describe('parseArchiveReference', () => { + it('parses standard 3-dash form', () => { + expect(parseArchiveReference('ДАКО-384-10-242')).toStrictEqual({ + archive: 'ДАКО', + fond: '384', + opys: '10', + sprava: '242', + raw: 'ДАКО-384-10-242', + }); + }); + + it('parses fond with cyrillic letter prefix', () => { + expect(parseArchiveReference('ДАХеО-Р2588-3-1')).toStrictEqual({ + archive: 'ДАХеО', + fond: 'Р2588', + opys: '3', + sprava: '1', + raw: 'ДАХеО-Р2588-3-1', + }); + }); + + it('parses sprava with trailing cyrillic letter', () => { + const result = parseArchiveReference('ДАХеО-Р3968-1-83а'); + expect(result.sprava).toBe('83а'); + }); + + it('parses opys containing letters', () => { + const result = parseArchiveReference('ДАКрО-П5907-2р-'); + expect(result.archive).toBe('ДАКрО'); + expect(result.fond).toBe('П5907'); + expect(result.opys).toBe('2р'); + expect(result.sprava).toBe(''); + }); + + it('parses empty sprava', () => { + const result = parseArchiveReference('ДАКО-384-15-'); + expect(result.sprava).toBe(''); + expect(result.fond).toBe('384'); + }); + + it('parses space as first separator', () => { + expect(parseArchiveReference('ДАХмО 315-1-8563')).toStrictEqual({ + archive: 'ДАХмО', + fond: '315', + opys: '1', + sprava: '8563', + raw: 'ДАХмО 315-1-8563', + }); + }); + + it('parses fond with separate-letter prefix (4 dashes)', () => { + expect(parseArchiveReference('ДАВіО-Р-6023-1-')).toStrictEqual({ + archive: 'ДАВіО', + fond: 'Р6023', + opys: '1', + sprava: '', + raw: 'ДАВіО-Р-6023-1-', + }); + }); + + it('returns unparsed for foreign archives', () => { + const result = parseArchiveReference('AGAD 298/151'); + expect(result.archive).toBe(''); + expect(result.raw).toBe('AGAD 298/151'); + }); + + it('returns unparsed for free-text references', () => { + const result = parseArchiveReference('див. джерела'); + expect(result.archive).toBe(''); + }); + + it('trims surrounding whitespace', () => { + expect(parseArchiveReference(' ДАКО-384-10-242 ').raw).toBe( + 'ДАКО-384-10-242', + ); + }); +}); diff --git a/src/shared/parse-archive-reference.ts b/src/shared/parse-archive-reference.ts new file mode 100644 index 00000000..cb4450e8 --- /dev/null +++ b/src/shared/parse-archive-reference.ts @@ -0,0 +1,52 @@ +export type ArchiveReference = { + archive: string; + fond: string; + opys: string; + sprava: string; + raw: string; +}; + +const CYRILLIC_WORD = /^\p{Script=Cyrillic}+$/u; +const SINGLE_CYRILLIC_LETTER = /^\p{Script=Cyrillic}$/u; + +const buildUnparsed = (raw: string): ArchiveReference => ({ + archive: '', + fond: '', + opys: '', + sprava: '', + raw, +}); + +export default function parseArchiveReference(raw: string): ArchiveReference { + const trimmed = raw.trim(); + const firstSeparator = trimmed.search(/[-\s]/); + if (firstSeparator === -1) return buildUnparsed(trimmed); + + const archive = trimmed.slice(0, firstSeparator); + if (!CYRILLIC_WORD.test(archive)) return buildUnparsed(trimmed); + + const rest = trimmed.slice(firstSeparator + 1); + const parts = rest.split('-'); + if (parts.length < 3) return buildUnparsed(trimmed); + + let fond: string; + let opys: string; + let sprava: string; + if (parts.length === 3) { + [fond, opys, sprava] = parts; + } else if (parts.length === 4 && SINGLE_CYRILLIC_LETTER.test(parts[0])) { + fond = `${parts[0]}${parts[1]}`; + opys = parts[2]; + sprava = parts[3]; + } else { + return buildUnparsed(trimmed); + } + + return { + archive, + fond: fond.trim(), + opys: opys.trim(), + sprava: sprava.trim(), + raw: trimmed, + }; +} From 0936dc6d5c23e8f932fa671e5376d3619d0a9b21 Mon Sep 17 00:00:00 2001 From: kavryn <1200215+kavryn@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:58:17 +0300 Subject: [PATCH 2/4] fix: polish sources page copy, semantics, and sitemap --- src/app/sitemap.ts | 5 +++++ src/app/sources/page.module.css | 27 ++++++++++++++++++++++----- src/app/sources/page.tsx | 31 ++++++++++++++++++------------- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 1b99f100..1c35fea6 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -58,6 +58,11 @@ export default async function sitemap(): Promise { lastModified: new Date(), url: new URL('/tables/', environment.NEXT_PUBLIC_SITE).toString(), }, + { + changeFrequency: 'daily', + lastModified: new Date(), + url: new URL('/sources/', environment.NEXT_PUBLIC_SITE).toString(), + }, { changeFrequency: 'monthly', lastModified: new Date(), diff --git a/src/app/sources/page.module.css b/src/app/sources/page.module.css index cb63239b..6005e611 100644 --- a/src/app/sources/page.module.css +++ b/src/app/sources/page.module.css @@ -1,10 +1,27 @@ .container { - padding: var(--side-gap) 0; + max-width: 1024px; + margin: 0 auto; + padding: 2rem 1rem; +} + +.header { + text-align: center; + margin-bottom: 2.5rem; +} + +.title { + font-size: 2.25rem; + line-height: 2.5rem; + font-weight: 700; + margin-bottom: 1rem; } .subtitle { - color: var(--description-color); - margin: 0.5rem 0 1rem; + font-size: 1.125rem; + line-height: 1.75rem; + margin-left: auto; + margin-right: auto; + max-width: 42rem; } .tableWrapper { @@ -58,8 +75,8 @@ } .raw { - color: var(--description-color); - font-style: italic; + color: inherit; + font-style: normal; } .tablesCell { diff --git a/src/app/sources/page.tsx b/src/app/sources/page.tsx index 9249d2be..cc060342 100644 --- a/src/app/sources/page.tsx +++ b/src/app/sources/page.tsx @@ -7,20 +7,24 @@ import SourcesFilter from './sources-filter'; import styles from './page.module.css'; +const TITLE = 'Джерела'; +const DESCRIPTION = + 'Перелік архівних справ, проіндексованих на Коренях повністю або частково.'; + export const metadata: Metadata = { - title: 'Джерела', - description: 'Перелік архівних справ на Коренях.', + title: TITLE, + description: DESCRIPTION, alternates: { canonical: '/sources/', }, openGraph: { - title: 'Джерела', - description: 'Перелік архівних справ на Коренях.', + title: TITLE, + description: DESCRIPTION, url: '/sources/', }, twitter: { - title: 'Джерела', - description: 'Перелік архівних справ на Коренях.', + title: TITLE, + description: DESCRIPTION, }, }; @@ -37,12 +41,13 @@ export default async function SourcesPage() { const hasOther = sources.some((s) => !s.archive); return ( -
    -

    Джерела

    -

    - Архівні справи, з яких походять записи в таблицях Коренів. Перевірте, чи - проіндексована потрібна вам справа. -

    +
    +
    +

    + {TITLE} +

    +

    {DESCRIPTION}

    +
    -
    + ); } From 9c93417e3f16b14491b636efc098a17cead35dff Mon Sep 17 00:00:00 2001 From: kavryn <1200215+kavryn@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:00:16 +0300 Subject: [PATCH 3/4] Without dot --- src/app/sources/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/sources/page.tsx b/src/app/sources/page.tsx index cc060342..8475e264 100644 --- a/src/app/sources/page.tsx +++ b/src/app/sources/page.tsx @@ -9,7 +9,7 @@ import styles from './page.module.css'; const TITLE = 'Джерела'; const DESCRIPTION = - 'Перелік архівних справ, проіндексованих на Коренях повністю або частково.'; + 'Перелік архівних справ, проіндексованих на Коренях повністю або частково'; export const metadata: Metadata = { title: TITLE, From 8b7fa02d29d9b12037ada60433b15aa8b2e2a9b9 Mon Sep 17 00:00:00 2001 From: kavryn <1200215+kavryn@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:30:08 +0300 Subject: [PATCH 4/4] fix: add empty state and correct plural rules on sources page --- src/app/sources/page.module.css | 4 +- src/app/sources/page.spec.tsx | 16 ++++++++ src/app/sources/page.tsx | 29 ++++++++++++- src/app/sources/sources-filter.test.tsx | 54 ++++++++++++++++++++++++- src/app/sources/sources-filter.tsx | 5 ++- 5 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/app/sources/page.module.css b/src/app/sources/page.module.css index 6005e611..7dfe4988 100644 --- a/src/app/sources/page.module.css +++ b/src/app/sources/page.module.css @@ -45,9 +45,6 @@ .table th { background: var(--table-row-header-bg); color: var(--table-row-header-text); - position: sticky; - top: 0; - z-index: 1; } .table th.centered { @@ -94,4 +91,5 @@ .empty { padding: 1rem 0; color: var(--description-color); + text-align: center; } diff --git a/src/app/sources/page.spec.tsx b/src/app/sources/page.spec.tsx index 02693c55..059d90a4 100644 --- a/src/app/sources/page.spec.tsx +++ b/src/app/sources/page.spec.tsx @@ -92,6 +92,22 @@ describe('SourcesPage', () => { expect(screen.getByText('6 таблиць')).toBeDefined(); }); + it('uses the correct Ukrainian plural form for large table counts', async () => { + vi.mocked(getArchiveSources).mockResolvedValue([ + buildSource({ + tables: Array.from({ length: 21 }, (_, index) => ({ + id: `t${index}`, + title: `Table ${index}`, + })), + }), + ]); + + const jsx = await SourcesPage(); + render(jsx); + + expect(screen.getByText('21 таблиця')).toBeDefined(); + }); + it('renders unparsed sources with raw text spanning the code columns', async () => { vi.mocked(getArchiveSources).mockResolvedValue([ buildSource({ diff --git a/src/app/sources/page.tsx b/src/app/sources/page.tsx index 8475e264..37b01ed6 100644 --- a/src/app/sources/page.tsx +++ b/src/app/sources/page.tsx @@ -10,6 +10,7 @@ import styles from './page.module.css'; const TITLE = 'Джерела'; const DESCRIPTION = 'Перелік архівних справ, проіндексованих на Коренях повністю або частково'; +const TABLES_PLURAL_RULES = new Intl.PluralRules('uk-UA'); export const metadata: Metadata = { title: TITLE, @@ -33,6 +34,24 @@ function formatYears(range: readonly number[]): string { return `${range[0]}–${range[1]}`; } +function formatTablesCount(count: number): string { + const label = (() => { + switch (TABLES_PLURAL_RULES.select(count)) { + case 'one': { + return 'таблиця'; + } + case 'few': { + return 'таблиці'; + } + default: { + return 'таблиць'; + } + } + })(); + + return `${count} ${label}`; +} + export default async function SourcesPage() { const sources = await getArchiveSources(); const archives = [ @@ -53,6 +72,12 @@ export default async function SourcesPage() { archives={archives} hasOther={hasOther} totalCount={sources.length} + emptyState={ +

    + Нічого не знайдено для поточних фільтрів. Спробуйте змінити умови + пошуку. +

    + } >
    @@ -93,7 +118,9 @@ export default async function SourcesPage() {
    {source.tables.length >= 5 ? (
    - {source.tables.length} таблиць + + {formatTablesCount(source.tables.length)} + {source.tables.map((t) => ( {t.title} diff --git a/src/app/sources/sources-filter.test.tsx b/src/app/sources/sources-filter.test.tsx index b6a1e651..e8d64786 100644 --- a/src/app/sources/sources-filter.test.tsx +++ b/src/app/sources/sources-filter.test.tsx @@ -5,6 +5,7 @@ import { screen, waitFor, } from '@testing-library/react'; +import type { ReactNode } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; import SourcesFilter from './sources-filter'; @@ -12,9 +13,19 @@ import SourcesFilter from './sources-filter'; function renderFilter({ archives = ['ДАКО', 'ДАЖО'], hasOther = true, -}: { archives?: string[]; hasOther?: boolean } = {}) { + emptyState =

    Нічого не знайдено

    , +}: { + archives?: string[]; + hasOther?: boolean; + emptyState?: ReactNode; +} = {}) { return render( - + screen.getByText(label).closest('tr') as HTMLTableRowElement; +const getTable = () => screen.queryByRole('table'); + const waitForDisplays = ( rowA: string, rowB: string, @@ -59,6 +72,21 @@ const waitForDisplays = ( return triple; }); +const waitForTableToDisappear = (): Promise => + waitFor(() => { + if (getTable()) { + throw new Error('expected table to be hidden'); + } + }); + +const waitForTableToAppear = (): Promise => + waitFor(() => { + const table = getTable(); + if (!table) { + throw new Error('expected table to be visible'); + } + }); + describe('SourcesFilter', () => { afterEach(() => { cleanup(); @@ -134,10 +162,32 @@ describe('SourcesFilter', () => { fireEvent.change(screen.getByLabelText('Фонд'), { target: { value: '999' }, }); + await waitForTableToDisappear(); fireEvent.click(screen.getByRole('button', { name: 'Скинути' })); + await waitForTableToAppear(); expect(await waitForDisplays('', '', '')).toStrictEqual(['', '', '']); }); + it('shows empty state when no rows match the filters', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Фонд'), { + target: { value: '999' }, + }); + + await waitForTableToDisappear(); + expect(screen.getByText('Нічого не знайдено')).toBeDefined(); + }); + + it('hides the table header when no rows match the filters', async () => { + renderFilter(); + fireEvent.change(screen.getByLabelText('Фонд'), { + target: { value: '999' }, + }); + + await waitForTableToDisappear(); + expect(screen.queryByRole('columnheader', { name: 'Архів' })).toBeNull(); + }); + it('hides "Інші джерела" option when hasOther is false', () => { renderFilter({ hasOther: false }); expect(screen.queryByText('Інші джерела')).toBeNull(); diff --git a/src/app/sources/sources-filter.tsx b/src/app/sources/sources-filter.tsx index d877fb25..c1179e05 100644 --- a/src/app/sources/sources-filter.tsx +++ b/src/app/sources/sources-filter.tsx @@ -15,6 +15,7 @@ type Properties = { hasOther: boolean; totalCount: number; children: ReactNode; + emptyState?: ReactNode; }; const OTHER = '__other__'; @@ -30,6 +31,7 @@ export default function SourcesFilter({ hasOther, totalCount, children, + emptyState, }: Properties) { const containerReference = useRef(null); const [archive, setArchive] = useState(''); @@ -154,7 +156,8 @@ export default function SourcesFilter({ Показано {visibleCount.toLocaleString('uk-UA')} з{' '} {totalCount.toLocaleString('uk-UA')} - {children} + {visibleCount === 0 && emptyState} + ); }