refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { abbreviateName, getInitials, personAvatarColor } from '$lib/utils/personFormat';
|
||||
import { abbreviateName, getInitials, personAvatarColor } from '$lib/person/personFormat';
|
||||
|
||||
type Person = { id: string; firstName?: string | null; lastName: string; displayName: string };
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
||||
import { formatLifeDateRange } from '$lib/person/personLifeDates';
|
||||
import { chipLabel, otherName } from '$lib/person/relationshipLabels';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { LoadState } from '$lib/types/personHoverCard';
|
||||
import type { LoadState } from '$lib/person/personHoverCard';
|
||||
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { radioGroupNav } from '$lib/actions/radioGroupNav';
|
||||
import { radioGroupNav } from '$lib/shared/actions/radioGroupNav';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { PERSON_TYPES as TYPES, type PersonType } from '$lib/person/person-validation';
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { untrack } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { clickOutside } from '$lib/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/hooks/useTypeahead.svelte';
|
||||
import { clickOutside } from '$lib/shared/actions/clickOutside';
|
||||
import { createTypeahead } from '$lib/shared/hooks/useTypeahead.svelte';
|
||||
import FieldLabelBadge from '$lib/document/FieldLabelBadge.svelte';
|
||||
type Person = components['schemas']['Person'];
|
||||
|
||||
|
||||
196
frontend/src/lib/person/personFormat.spec.ts
Normal file
196
frontend/src/lib/person/personFormat.spec.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getInitials,
|
||||
abbreviateName,
|
||||
formatXsMeta,
|
||||
personAvatarColor,
|
||||
statusDotClass,
|
||||
statusLabel
|
||||
} from './personFormat';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
|
||||
// ─── getInitials ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getInitials', () => {
|
||||
it('returns first chars of first and last word uppercased', () => {
|
||||
expect(getInitials('Marcel Raddatz')).toBe('MR');
|
||||
});
|
||||
|
||||
it('returns single char for a single-word name', () => {
|
||||
expect(getInitials('Raddatz')).toBe('R');
|
||||
});
|
||||
|
||||
it('returns empty string for an empty name', () => {
|
||||
expect(getInitials('')).toBe('');
|
||||
});
|
||||
|
||||
it('splits on whitespace only — hyphenated first word counts as one', () => {
|
||||
expect(getInitials('Anna-Maria Raddatz')).toBe('AR');
|
||||
});
|
||||
|
||||
it('ignores extra whitespace between words', () => {
|
||||
expect(getInitials(' Karl Raddatz ')).toBe('KR');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 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');
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/person/personFormat.ts
Normal file
96
frontend/src/lib/person/personFormat.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { formatDocumentStatus } from '$lib/document/documentStatusLabel';
|
||||
import { formatDate } from '$lib/shared/utils/date';
|
||||
|
||||
type Person = { firstName?: string | null; lastName: string; displayName: 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 getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '';
|
||||
if (words.length === 1) return words[0].charAt(0).toUpperCase();
|
||||
return (words[0].charAt(0) + words[words.length - 1].charAt(0)).toUpperCase();
|
||||
}
|
||||
|
||||
export function abbreviateName(person: Person): string {
|
||||
if (!person.firstName) return person.lastName;
|
||||
const first = person.firstName.trim();
|
||||
const last = person.lastName.trim();
|
||||
if (!last) return first;
|
||||
return `${first.charAt(0)}. ${last}`;
|
||||
}
|
||||
|
||||
function abbreviateCompact(person: Person): string {
|
||||
if (!person.firstName) return person.lastName;
|
||||
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 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);
|
||||
}
|
||||
23
frontend/src/lib/person/personHoverCard.ts
Normal file
23
frontend/src/lib/person/personHoverCard.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Person = components['schemas']['Person'];
|
||||
type RelationshipDTO = components['schemas']['RelationshipDTO'];
|
||||
|
||||
/**
|
||||
* Data the PersonHoverCard needs to render its loaded state.
|
||||
* Bundled here so the orchestrator (TranscriptionReadView) and the view
|
||||
* (PersonHoverCard) share one canonical shape.
|
||||
*/
|
||||
export type HoverData = { person: Person; relationships: RelationshipDTO[] };
|
||||
|
||||
/**
|
||||
* The hover card's three visible states.
|
||||
*
|
||||
* `loading` — initial fetch in flight; skeleton is shown
|
||||
* `error` — fetch failed (non-404, non-OK); generic error message + footer link
|
||||
* `loaded` — fetch succeeded; person + relationships available
|
||||
*/
|
||||
export type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error' }
|
||||
| { status: 'loaded'; person: Person; relationships: RelationshipDTO[] };
|
||||
24
frontend/src/lib/person/personLifeDates.spec.ts
Normal file
24
frontend/src/lib/person/personLifeDates.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatLifeDateRange } from './personLifeDates';
|
||||
|
||||
describe('formatLifeDateRange', () => {
|
||||
it('returns both dates when birth and death year are given', () => {
|
||||
expect(formatLifeDateRange(1882, 1944)).toBe('* 1882 – † 1944');
|
||||
});
|
||||
|
||||
it('returns only birth year when only birthYear is given', () => {
|
||||
expect(formatLifeDateRange(1882, undefined)).toBe('* 1882');
|
||||
});
|
||||
|
||||
it('returns only death year when only deathYear is given', () => {
|
||||
expect(formatLifeDateRange(undefined, 1944)).toBe('† 1944');
|
||||
});
|
||||
|
||||
it('returns empty string when neither year is given', () => {
|
||||
expect(formatLifeDateRange(undefined, undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string when both are null', () => {
|
||||
expect(formatLifeDateRange(null, null)).toBe('');
|
||||
});
|
||||
});
|
||||
20
frontend/src/lib/person/personLifeDates.ts
Normal file
20
frontend/src/lib/person/personLifeDates.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Formats the life date range for a person.
|
||||
* Examples:
|
||||
* * 1882 – † 1944 (both)
|
||||
* * 1882 (birth only)
|
||||
* † 1944 (death only)
|
||||
* "" (neither)
|
||||
*/
|
||||
export function formatLifeDateRange(birthYear?: number | null, deathYear?: number | null): string {
|
||||
if (birthYear && deathYear) {
|
||||
return `* ${birthYear} – † ${deathYear}`;
|
||||
}
|
||||
if (birthYear) {
|
||||
return `* ${birthYear}`;
|
||||
}
|
||||
if (deathYear) {
|
||||
return `† ${deathYear}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
Reference in New Issue
Block a user