diff --git a/frontend/src/lib/utils/groupDocuments.spec.ts b/frontend/src/lib/utils/groupDocuments.spec.ts new file mode 100644 index 00000000..895a4d99 --- /dev/null +++ b/frontend/src/lib/utils/groupDocuments.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; +import { groupDocuments } from './groupDocuments'; + +type Doc = { + id: string; + documentDate?: string | null; + sender?: { displayName: string } | null; + receivers?: { displayName: string }[]; +}; + +const doc = (overrides: Partial & { id: string }): Doc => ({ + documentDate: null, + sender: null, + receivers: [], + ...overrides +}); + +// ─── DATE sort ─────────────────────────────────────────────────────────────── + +describe('groupDocuments — DATE sort', () => { + it('produces one group per distinct year', () => { + const docs = [ + doc({ id: 'a', documentDate: '1923-04-12' }), + doc({ id: 'b', documentDate: '1938-01-01' }), + doc({ id: 'c', documentDate: '1965-08-03' }) + ]; + const groups = groupDocuments(docs, 'DATE', 'Undatiert'); + expect(groups.map((g) => g.label)).toEqual(['1923', '1938', '1965']); + expect(groups.every((g) => g.documents.length === 1)).toBe(true); + }); + + it('puts multiple docs from the same year into one group', () => { + const docs = [ + doc({ id: 'a', documentDate: '1938-03-01' }), + doc({ id: 'b', documentDate: '1938-11-15' }) + ]; + const groups = groupDocuments(docs, 'DATE', 'Undatiert'); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe('1938'); + expect(groups[0].documents).toHaveLength(2); + }); + + it('places undated docs in the fallback group at the bottom', () => { + const docs = [ + doc({ id: 'a', documentDate: '1938-01-01' }), + doc({ id: 'b', documentDate: null }), + doc({ id: 'c', documentDate: null }) + ]; + const groups = groupDocuments(docs, 'DATE', 'Undatiert'); + expect(groups).toHaveLength(2); + expect(groups[0].label).toBe('1938'); + expect(groups[1].label).toBe('Undatiert'); + expect(groups[1].documents.map((d) => d.id)).toEqual(['b', 'c']); + }); + + it('returns one group with fallback label when all docs are undated', () => { + const docs = [doc({ id: 'a' }), doc({ id: 'b' })]; + const groups = groupDocuments(docs, 'DATE', 'Undatiert'); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe('Undatiert'); + }); + + it('returns one group when all docs are from the same year', () => { + const docs = [ + doc({ id: 'a', documentDate: '1938-01-01' }), + doc({ id: 'b', documentDate: '1938-06-15' }) + ]; + const groups = groupDocuments(docs, 'DATE', 'Undatiert'); + expect(groups).toHaveLength(1); + }); +}); + +// ─── SENDER sort ───────────────────────────────────────────────────────────── + +describe('groupDocuments — SENDER sort', () => { + it('produces one group per distinct sender', () => { + const docs = [ + doc({ id: 'a', sender: { displayName: 'Anna Müller' } }), + doc({ id: 'b', sender: { displayName: 'Karl Bauer' } }), + doc({ id: 'c', sender: { displayName: 'Anna Müller' } }) + ]; + const groups = groupDocuments(docs, 'SENDER', 'Unbekannt'); + expect(groups.map((g) => g.label)).toEqual(['Anna Müller', 'Karl Bauer']); + expect(groups[0].documents).toHaveLength(2); + expect(groups[1].documents).toHaveLength(1); + }); + + it('places docs with no sender in the fallback group at the bottom', () => { + const docs = [ + doc({ id: 'a', sender: { displayName: 'Anna Müller' } }), + doc({ id: 'b', sender: null }) + ]; + const groups = groupDocuments(docs, 'SENDER', 'Unbekannt'); + expect(groups).toHaveLength(2); + expect(groups[0].label).toBe('Anna Müller'); + expect(groups[1].label).toBe('Unbekannt'); + expect(groups[1].documents[0].id).toBe('b'); + }); +}); + +// ─── RECEIVER sort ─────────────────────────────────────────────────────────── + +describe('groupDocuments — RECEIVER sort', () => { + it('a doc with two receivers appears in both receiver groups', () => { + const docs = [ + doc({ + id: 'a', + receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }] + }) + ]; + const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt'); + expect(groups.map((g) => g.label)).toEqual(['Anna', 'Karl']); + expect(groups[0].documents[0].id).toBe('a'); + expect(groups[1].documents[0].id).toBe('a'); + }); + + it('places docs with no receivers in the fallback group at the bottom', () => { + const docs = [ + doc({ id: 'a', receivers: [{ displayName: 'Anna' }] }), + doc({ id: 'b', receivers: [] }) + ]; + const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt'); + expect(groups).toHaveLength(2); + expect(groups[0].label).toBe('Anna'); + expect(groups[1].label).toBe('Unbekannt'); + expect(groups[1].documents[0].id).toBe('b'); + }); + + it('composite keys are unique: groupLabel + doc.id identifies each item', () => { + const docs = [ + doc({ id: 'a', receivers: [{ displayName: 'Anna' }, { displayName: 'Karl' }] }), + doc({ id: 'b', receivers: [{ displayName: 'Anna' }] }) + ]; + const groups = groupDocuments(docs, 'RECEIVER', 'Unbekannt'); + const keys = groups.flatMap((g) => g.documents.map((d) => `${g.label}-${d.id}`)); + const uniqueKeys = new Set(keys); + expect(uniqueKeys.size).toBe(keys.length); + }); +}); + +// ─── Non-groupable sorts ────────────────────────────────────────────────────── + +describe('groupDocuments — non-groupable sorts', () => { + it('TITLE sort returns one group containing all documents', () => { + const docs = [doc({ id: 'a' }), doc({ id: 'b' })]; + const groups = groupDocuments(docs, 'TITLE', 'Undatiert'); + expect(groups).toHaveLength(1); + expect(groups[0].documents).toHaveLength(2); + }); + + it('UPLOAD_DATE sort returns one group containing all documents', () => { + const docs = [doc({ id: 'a' }), doc({ id: 'b' })]; + const groups = groupDocuments(docs, 'UPLOAD_DATE', 'Undatiert'); + expect(groups).toHaveLength(1); + expect(groups[0].documents).toHaveLength(2); + }); +}); + +// ─── Edge cases ────────────────────────────────────────────────────────────── + +describe('groupDocuments — edge cases', () => { + it('returns empty array for an empty document list', () => { + expect(groupDocuments([], 'DATE', 'Undatiert')).toEqual([]); + }); +}); diff --git a/frontend/src/lib/utils/groupDocuments.ts b/frontend/src/lib/utils/groupDocuments.ts new file mode 100644 index 00000000..d1926415 --- /dev/null +++ b/frontend/src/lib/utils/groupDocuments.ts @@ -0,0 +1,56 @@ +export type GroupableDoc = { + id: string; + documentDate?: string | null; + sender?: { displayName: string } | null; + receivers?: { displayName: string }[]; +}; + +export type DocumentGroup = { + label: string; + documents: T[]; +}; + +const GROUPABLE_SORTS = ['DATE', 'SENDER', 'RECEIVER'] as const; +type GroupableSort = (typeof GROUPABLE_SORTS)[number]; + +export function groupDocuments( + docs: T[], + sort: string, + fallbackLabel: string +): DocumentGroup[] { + if (docs.length === 0) return []; + if (!GROUPABLE_SORTS.includes(sort as GroupableSort)) { + return [{ label: '', documents: [...docs] }]; + } + + const groupMap = new Map(); + const fallbackDocs: T[] = []; + + for (const doc of docs) { + const keys = extractGroupKeys(doc, sort as GroupableSort); + if (keys.length === 0) { + fallbackDocs.push(doc); + } else { + for (const key of keys) { + if (!groupMap.has(key)) groupMap.set(key, []); + groupMap.get(key)!.push(doc); + } + } + } + + const groups = [...groupMap.entries()].map(([label, documents]) => ({ label, documents })); + if (fallbackDocs.length > 0) groups.push({ label: fallbackLabel, documents: fallbackDocs }); + return groups; +} + +function extractGroupKeys(doc: T, sort: GroupableSort): string[] { + if (sort === 'DATE') { + const year = doc.documentDate + ? String(new Date(doc.documentDate + 'T12:00:00').getFullYear()) + : null; + return year ? [year] : []; + } + if (sort === 'SENDER') return doc.sender ? [doc.sender.displayName] : []; + if (sort === 'RECEIVER') return (doc.receivers ?? []).map((r) => r.displayName); + return []; +}