diff --git a/frontend/src/lib/utils/personFormat.spec.ts b/frontend/src/lib/utils/personFormat.spec.ts new file mode 100644 index 00000000..257dd02e --- /dev/null +++ b/frontend/src/lib/utils/personFormat.spec.ts @@ -0,0 +1,171 @@ +import { describe, it, expect } from 'vitest'; +import { + abbreviateName, + formatXsMeta, + personAvatarColor, + formatDate, + statusDotClass, + statusLabel +} from './personFormat'; + +// ─── abbreviateName ────────────────────────────────────────────────────────── + +describe('abbreviateName', () => { + it('abbreviates first name to initial + last name', () => { + expect(abbreviateName({ firstName: 'Karl', lastName: 'Raddatz' })).toBe('K. Raddatz'); + }); + + it('returns single name as-is when no last name', () => { + expect(abbreviateName({ firstName: 'Elfriede', lastName: '' })).toBe('Elfriede'); + }); + + it('preserves hyphenated last name', () => { + expect(abbreviateName({ firstName: 'Karl', lastName: 'Müller-Schmidt' })).toBe( + 'K. Müller-Schmidt' + ); + }); + + it('handles leading/trailing whitespace in names', () => { + expect(abbreviateName({ firstName: ' Karl ', lastName: ' Raddatz ' })).toBe('K. Raddatz'); + }); +}); + +// ─── formatXsMeta ──────────────────────────────────────────────────────────── + +type Doc = { + sender?: { firstName: string; lastName: string } | null; + receivers?: { firstName: string; lastName: string }[]; + documentDate?: string | null; +}; + +describe('formatXsMeta', () => { + const sender = { firstName: 'Karl', lastName: 'Raddatz' }; + const receiver1 = { firstName: 'Elfriede', lastName: 'Raddatz' }; + const receiver2 = { firstName: 'Anna', lastName: 'Müller' }; + const receiver3 = { firstName: 'Hans', lastName: 'Schmidt' }; + + it('formats sender with no receivers and date', () => { + const doc: Doc = { sender, receivers: [], documentDate: '1943-12-24' }; + expect(formatXsMeta(doc)).toBe('K.Raddatz · 24.12.1943'); + }); + + it('formats sender with one receiver and date', () => { + const doc: Doc = { sender, receivers: [receiver1], documentDate: '1943-12-24' }; + expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz · 24.12.1943'); + }); + + it('formats sender with three receivers showing +2', () => { + const doc: Doc = { + sender, + receivers: [receiver1, receiver2, receiver3], + documentDate: '1943-12-24' + }; + expect(formatXsMeta(doc)).toBe('K.Raddatz → E.Raddatz +2 · 24.12.1943'); + }); + + it('formats without sender', () => { + const doc: Doc = { sender: null, receivers: [receiver1], documentDate: '1943-12-24' }; + expect(formatXsMeta(doc)).toBe('E.Raddatz · 24.12.1943'); + }); + + it('formats without date', () => { + const doc: Doc = { sender, receivers: [], documentDate: null }; + expect(formatXsMeta(doc)).toBe('K.Raddatz'); + }); + + it('formats with no sender and no date', () => { + const doc: Doc = { sender: null, receivers: [receiver1], documentDate: null }; + expect(formatXsMeta(doc)).toBe('E.Raddatz'); + }); +}); + +// ─── personAvatarColor ─────────────────────────────────────────────────────── + +const PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020']; + +describe('personAvatarColor', () => { + it('returns a value from the palette', () => { + expect(PALETTE).toContain(personAvatarColor('abc')); + }); + + it('is deterministic — same id always returns same color', () => { + const id = '550e8400-e29b-41d4-a716-446655440000'; + expect(personAvatarColor(id)).toBe(personAvatarColor(id)); + }); + + it('all 5 palette entries are reachable across 1000 random UUIDs', () => { + const seen = new Set(); + for (let i = 0; i < 1000; i++) { + seen.add(personAvatarColor(crypto.randomUUID())); + } + expect(seen.size).toBe(5); + }); +}); + +// ─── formatDate ────────────────────────────────────────────────────────────── + +describe('formatDate', () => { + it('formats short date as dd.mm.yyyy', () => { + expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943'); + }); + + it('formats long date with German month name', () => { + expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943'); + }); + + it('does not shift Dec 31 to Jan 1 (UTC off-by-one guard)', () => { + expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943'); + }); + + it('does not shift Jan 1 to Dec 31 (UTC off-by-one guard)', () => { + expect(formatDate('1944-01-01', 'short')).toBe('01.01.1944'); + }); +}); + +// ─── statusDotClass ────────────────────────────────────────────────────────── + +describe('statusDotClass', () => { + it('PLACEHOLDER → bg-gray-400', () => { + expect(statusDotClass('PLACEHOLDER')).toBe('bg-gray-400'); + }); + + it('UPLOADED → bg-emerald-500', () => { + expect(statusDotClass('UPLOADED')).toBe('bg-emerald-500'); + }); + + it('TRANSCRIBED → bg-blue-400', () => { + expect(statusDotClass('TRANSCRIBED')).toBe('bg-blue-400'); + }); + + it('REVIEWED → bg-amber-400', () => { + expect(statusDotClass('REVIEWED')).toBe('bg-amber-400'); + }); + + it('ARCHIVED → bg-emerald-600', () => { + expect(statusDotClass('ARCHIVED')).toBe('bg-emerald-600'); + }); +}); + +// ─── statusLabel ───────────────────────────────────────────────────────────── + +describe('statusLabel', () => { + it('PLACEHOLDER → "Platzhalter"', () => { + expect(statusLabel('PLACEHOLDER')).toBe('Platzhalter'); + }); + + it('UPLOADED → "Hochgeladen"', () => { + expect(statusLabel('UPLOADED')).toBe('Hochgeladen'); + }); + + it('TRANSCRIBED → "Transkribiert"', () => { + expect(statusLabel('TRANSCRIBED')).toBe('Transkribiert'); + }); + + it('REVIEWED → "Geprüft"', () => { + expect(statusLabel('REVIEWED')).toBe('Geprüft'); + }); + + it('ARCHIVED → "Archiviert"', () => { + expect(statusLabel('ARCHIVED')).toBe('Archiviert'); + }); +}); diff --git a/frontend/src/lib/utils/personFormat.ts b/frontend/src/lib/utils/personFormat.ts new file mode 100644 index 00000000..ffa2db5c --- /dev/null +++ b/frontend/src/lib/utils/personFormat.ts @@ -0,0 +1,102 @@ +import { formatDocumentStatus } from './documentStatusLabel'; + +type Person = { firstName: string; lastName: string }; +type DocumentStatus = 'PLACEHOLDER' | 'UPLOADED' | 'TRANSCRIBED' | 'REVIEWED' | 'ARCHIVED'; +type DocForMeta = { + sender?: Person | null; + receivers?: Person[]; + documentDate?: string | null; +}; + +const AVATAR_PALETTE = ['#012851', '#5A3080', '#007596', '#2A6040', '#803020'] as const; + +function djb2(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return Math.abs(hash); +} + +export function abbreviateName(person: Person): string { + const first = person.firstName.trim(); + const last = person.lastName.trim(); + if (!last) return first; + return `${first.charAt(0)}. ${last}`; +} + +function abbreviateCompact(person: Person): string { + const first = person.firstName.trim(); + const last = person.lastName.trim(); + if (!last) return first; + return `${first.charAt(0)}.${last}`; +} + +export function formatXsMeta(doc: DocForMeta): string { + const parts: string[] = []; + + const receivers = doc.receivers ?? []; + if (doc.sender) { + const senderAbbr = abbreviateCompact(doc.sender); + if (receivers.length === 0) { + parts.push(senderAbbr); + } else { + const extra = receivers.length - 1; + const firstReceiver = abbreviateCompact(receivers[0]); + parts.push( + extra > 0 + ? `${senderAbbr} → ${firstReceiver} +${extra}` + : `${senderAbbr} → ${firstReceiver}` + ); + } + } else if (receivers.length > 0) { + const extra = receivers.length - 1; + const firstReceiver = abbreviateCompact(receivers[0]); + parts.push(extra > 0 ? `${firstReceiver} +${extra}` : firstReceiver); + } + + if (doc.documentDate) { + parts.push(formatDate(doc.documentDate, 'short')); + } + + return parts.join(' · '); +} + +export function personAvatarColor(personId: string): string { + return AVATAR_PALETTE[djb2(personId) % AVATAR_PALETTE.length]; +} + +export function formatDate(isoDate: string, format: 'short' | 'long'): string { + const date = new Date(isoDate + 'T12:00:00'); + if (format === 'short') { + return new Intl.DateTimeFormat('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }).format(date); + } + return new Intl.DateTimeFormat('de-DE', { + day: 'numeric', + month: 'long', + year: 'numeric' + }).format(date); +} + +export function statusDotClass(status: DocumentStatus): string { + switch (status) { + case 'PLACEHOLDER': + return 'bg-gray-400'; + case 'UPLOADED': + return 'bg-emerald-500'; + case 'TRANSCRIBED': + return 'bg-blue-400'; + case 'REVIEWED': + return 'bg-amber-400'; + case 'ARCHIVED': + return 'bg-emerald-600'; + } +} + +export function statusLabel(status: string): string { + return formatDocumentStatus(status); +}