feat(utils): add personFormat utility module with 6 pure functions (TDD)

abbreviateName, formatXsMeta, personAvatarColor (djb2), formatDate,
statusDotClass, statusLabel — 27 tests all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-03-31 22:39:44 +02:00
parent b5a68e69e2
commit 27254fb0ac
2 changed files with 273 additions and 0 deletions

View File

@@ -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<string>();
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');
});
});

View File

@@ -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);
}