refactor: move lib-root files to lib/shared/ and finalize domain structure
- Move api.server.ts, errors.ts, types.ts, utils.ts, relativeTime.ts to lib/shared/ - Move person relationship components to lib/person/relationship/ - Move Stammbaum components to lib/person/genealogy/ - Move HelpPopover to lib/shared/primitives/ - Update all import paths across routes, specs, and lib files - Update vi.mock() paths in server-project test files - Remove now-empty legacy directories (components/, hooks/, server/, etc.) - Update vite.config.ts coverage include paths for new structure - Update frontend/CLAUDE.md to reflect domain-based lib/ layout Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
25
frontend/src/lib/shared/api.server.ts
Normal file
25
frontend/src/lib/shared/api.server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Typed API client for the Familienarchiv backend.
|
||||
*
|
||||
* Types are generated from the OpenAPI spec — run `npm run generate:api`
|
||||
* (with the backend running in dev mode) to regenerate src/lib/generated/api.ts.
|
||||
*
|
||||
* Usage in +page.server.ts:
|
||||
*
|
||||
* export async function load({ fetch }) {
|
||||
* const api = createApiClient(fetch);
|
||||
* const { data, error } = await api.GET('/api/documents/{id}', {
|
||||
* params: { path: { id } }
|
||||
* });
|
||||
* }
|
||||
*/
|
||||
import createClient from 'openapi-fetch';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { paths } from '$lib/generated/api';
|
||||
|
||||
export function createApiClient(fetch: typeof globalThis.fetch) {
|
||||
return createClient<paths>({
|
||||
baseUrl: env.API_INTERNAL_URL || 'http://localhost:8080',
|
||||
fetch
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { FlatMessage } from '$lib/types';
|
||||
import type { FlatMessage } from '$lib/shared/types';
|
||||
import { extractQuote } from '$lib/shared/discussion/comment';
|
||||
import { getInitials } from '$lib/person/personFormat';
|
||||
import { relativeTime } from '$lib/shared/utils/time';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import CommentMessage from './CommentMessage.svelte';
|
||||
import type { FlatMessage } from '$lib/types';
|
||||
import type { FlatMessage } from '$lib/shared/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { Comment, FlatMessage, MentionDTO } from '$lib/types';
|
||||
import type { Comment, FlatMessage, MentionDTO } from '$lib/shared/types';
|
||||
import MentionEditor from '$lib/shared/discussion/MentionEditor.svelte';
|
||||
import CommentMessage from '$lib/shared/discussion/CommentMessage.svelte';
|
||||
import { extractContent } from '$lib/shared/discussion/mention';
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import CommentThread from './CommentThread.svelte';
|
||||
import type { Comment } from '$lib/types';
|
||||
import type { Comment } from '$lib/shared/types';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, tick } from 'svelte';
|
||||
import { detectMention } from '$lib/shared/discussion/mention';
|
||||
import type { MentionDTO } from '$lib/types';
|
||||
import type { MentionDTO } from '$lib/shared/types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import StarterKit from '@tiptap/starter-kit';
|
||||
import { Mention } from '@tiptap/extension-mention';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
import type { PersonMention } from '$lib/shared/types';
|
||||
import { deserialize, serialize } from '$lib/shared/discussion/mentionSerializer';
|
||||
import MentionDropdown from './MentionDropdown.svelte';
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
renderBody,
|
||||
renderTranscriptionBody
|
||||
} from './mention';
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
import type { MentionDTO, PersonMention } from '$lib/shared/types';
|
||||
|
||||
// ─── escapeHtml ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MentionDTO, PersonMention } from '$lib/types';
|
||||
import type { MentionDTO, PersonMention } from '$lib/shared/types';
|
||||
|
||||
/**
|
||||
* Single-source CSS selector for rendered person-mention anchors. Used by:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deserialize, serialize } from './mentionSerializer';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
import type { PersonMention } from '$lib/shared/types';
|
||||
|
||||
// ─── deserialize ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { JSONContent } from '@tiptap/core';
|
||||
import type { PersonMention } from '$lib/types';
|
||||
import type { PersonMention } from '$lib/shared/types';
|
||||
|
||||
/**
|
||||
* Converts stored block text + sidecar into a Tiptap ProseMirror document.
|
||||
|
||||
166
frontend/src/lib/shared/errors.ts
Normal file
166
frontend/src/lib/shared/errors.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
/**
|
||||
* Mirror of the backend ErrorCode enum.
|
||||
* Keep in sync with backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
|
||||
*/
|
||||
export type ErrorCode =
|
||||
| 'PERSON_NOT_FOUND'
|
||||
| 'ALIAS_NOT_FOUND'
|
||||
| 'INVALID_PERSON_TYPE'
|
||||
| 'DOCUMENT_NOT_FOUND'
|
||||
| 'DOCUMENT_NO_FILE'
|
||||
| 'FILE_NOT_FOUND'
|
||||
| 'FILE_UPLOAD_FAILED'
|
||||
| 'UNSUPPORTED_FILE_TYPE'
|
||||
| 'USER_NOT_FOUND'
|
||||
| 'EMAIL_ALREADY_IN_USE'
|
||||
| 'WRONG_CURRENT_PASSWORD'
|
||||
| 'IMPORT_ALREADY_RUNNING'
|
||||
| 'INVALID_RESET_TOKEN'
|
||||
| 'INVITE_NOT_FOUND'
|
||||
| 'INVITE_EXHAUSTED'
|
||||
| 'INVITE_REVOKED'
|
||||
| 'INVITE_EXPIRED'
|
||||
| 'ANNOTATION_NOT_FOUND'
|
||||
| 'ANNOTATION_UPDATE_FAILED'
|
||||
| 'TRANSCRIPTION_BLOCK_NOT_FOUND'
|
||||
| 'TRANSCRIPTION_BLOCK_CONFLICT'
|
||||
| 'COMMENT_NOT_FOUND'
|
||||
| 'OCR_SERVICE_UNAVAILABLE'
|
||||
| 'OCR_JOB_NOT_FOUND'
|
||||
| 'OCR_DOCUMENT_NOT_UPLOADED'
|
||||
| 'OCR_PROCESSING_FAILED'
|
||||
| 'TRAINING_ALREADY_RUNNING'
|
||||
| 'OCR_TRAINING_CONFLICT'
|
||||
| 'INVALID_TAG_COLOR'
|
||||
| 'TAG_CYCLE_DETECTED'
|
||||
| 'TAG_NOT_FOUND'
|
||||
| 'TAG_MERGE_SELF'
|
||||
| 'TAG_MERGE_INVALID_TARGET'
|
||||
| 'RELATIONSHIP_NOT_FOUND'
|
||||
| 'CIRCULAR_RELATIONSHIP'
|
||||
| 'DUPLICATE_RELATIONSHIP'
|
||||
| 'GESCHICHTE_NOT_FOUND'
|
||||
| 'MISSING_CREDENTIALS'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION_ERROR'
|
||||
| 'BATCH_TOO_LARGE'
|
||||
| 'BULK_EDIT_TOO_MANY_IDS'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
export interface BackendError {
|
||||
code: ErrorCode;
|
||||
message: string; // English developer message — not shown to users
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse a backend ErrorResponse from a failed fetch response.
|
||||
* Returns null if the body is not valid JSON or does not contain a code field.
|
||||
*/
|
||||
export async function parseBackendError(res: Response): Promise<BackendError | null> {
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && typeof body.code === 'string') {
|
||||
return body as BackendError;
|
||||
}
|
||||
} catch {
|
||||
// Body was not JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Returns a localised message for the given error code via Paraglide. Falls back to INTERNAL_ERROR. */
|
||||
export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
||||
switch (code) {
|
||||
case 'PERSON_NOT_FOUND':
|
||||
return m.error_person_not_found();
|
||||
case 'ALIAS_NOT_FOUND':
|
||||
return m.error_alias_not_found();
|
||||
case 'INVALID_PERSON_TYPE':
|
||||
return m.error_invalid_person_type();
|
||||
case 'DOCUMENT_NOT_FOUND':
|
||||
return m.error_document_not_found();
|
||||
case 'DOCUMENT_NO_FILE':
|
||||
return m.error_document_no_file();
|
||||
case 'FILE_NOT_FOUND':
|
||||
return m.error_file_not_found();
|
||||
case 'FILE_UPLOAD_FAILED':
|
||||
return m.error_file_upload_failed();
|
||||
case 'UNSUPPORTED_FILE_TYPE':
|
||||
return m.error_unsupported_file_type();
|
||||
case 'USER_NOT_FOUND':
|
||||
return m.error_user_not_found();
|
||||
case 'EMAIL_ALREADY_IN_USE':
|
||||
return m.error_email_already_in_use();
|
||||
case 'WRONG_CURRENT_PASSWORD':
|
||||
return m.error_wrong_current_password();
|
||||
case 'IMPORT_ALREADY_RUNNING':
|
||||
return m.error_import_already_running();
|
||||
case 'INVALID_RESET_TOKEN':
|
||||
return m.error_invalid_reset_token();
|
||||
case 'INVITE_NOT_FOUND':
|
||||
return m.error_invite_not_found();
|
||||
case 'INVITE_EXHAUSTED':
|
||||
return m.error_invite_exhausted();
|
||||
case 'INVITE_REVOKED':
|
||||
return m.error_invite_revoked();
|
||||
case 'INVITE_EXPIRED':
|
||||
return m.error_invite_expired();
|
||||
case 'ANNOTATION_NOT_FOUND':
|
||||
return m.error_annotation_not_found();
|
||||
case 'ANNOTATION_UPDATE_FAILED':
|
||||
return m.error_annotation_update_failed();
|
||||
case 'TRANSCRIPTION_BLOCK_NOT_FOUND':
|
||||
return m.error_transcription_block_not_found();
|
||||
case 'TRANSCRIPTION_BLOCK_CONFLICT':
|
||||
return m.error_transcription_block_conflict();
|
||||
case 'COMMENT_NOT_FOUND':
|
||||
return m.error_comment_not_found();
|
||||
case 'OCR_SERVICE_UNAVAILABLE':
|
||||
return m.error_ocr_service_unavailable();
|
||||
case 'OCR_JOB_NOT_FOUND':
|
||||
return m.error_ocr_job_not_found();
|
||||
case 'OCR_DOCUMENT_NOT_UPLOADED':
|
||||
return m.error_ocr_document_not_uploaded();
|
||||
case 'OCR_PROCESSING_FAILED':
|
||||
return m.error_ocr_processing_failed();
|
||||
case 'TRAINING_ALREADY_RUNNING':
|
||||
return m.error_training_already_running();
|
||||
case 'OCR_TRAINING_CONFLICT':
|
||||
return m.error_internal_error();
|
||||
case 'INVALID_TAG_COLOR':
|
||||
return m.error_invalid_tag_color();
|
||||
case 'TAG_CYCLE_DETECTED':
|
||||
return m.error_tag_cycle_detected();
|
||||
case 'TAG_NOT_FOUND':
|
||||
return m.error_tag_not_found();
|
||||
case 'TAG_MERGE_SELF':
|
||||
return m.error_tag_merge_self();
|
||||
case 'TAG_MERGE_INVALID_TARGET':
|
||||
return m.error_tag_merge_invalid_target();
|
||||
case 'RELATIONSHIP_NOT_FOUND':
|
||||
return m.error_relationship_not_found();
|
||||
case 'CIRCULAR_RELATIONSHIP':
|
||||
return m.error_circular_relationship();
|
||||
case 'DUPLICATE_RELATIONSHIP':
|
||||
return m.error_duplicate_relationship();
|
||||
case 'GESCHICHTE_NOT_FOUND':
|
||||
return m.error_geschichte_not_found();
|
||||
case 'MISSING_CREDENTIALS':
|
||||
return m.login_error_missing_credentials();
|
||||
case 'UNAUTHORIZED':
|
||||
return m.error_unauthorized();
|
||||
case 'FORBIDDEN':
|
||||
return m.error_forbidden();
|
||||
case 'VALIDATION_ERROR':
|
||||
return m.error_validation_error();
|
||||
case 'BATCH_TOO_LARGE':
|
||||
return m.error_batch_too_large();
|
||||
case 'BULK_EDIT_TOO_MANY_IDS':
|
||||
return m.error_bulk_edit_too_many_ids();
|
||||
default:
|
||||
return m.error_internal_error();
|
||||
}
|
||||
}
|
||||
69
frontend/src/lib/shared/relativeTime.spec.ts
Normal file
69
frontend/src/lib/shared/relativeTime.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { relativeTimeDe, relativeYearsDe } from './relativeTime';
|
||||
|
||||
const NOW = new Date('2026-04-20T12:00:00Z');
|
||||
|
||||
describe('relativeTimeDe', () => {
|
||||
it('returns minutes for a gap under one hour', () => {
|
||||
const from = new Date('2026-04-20T11:58:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
|
||||
it('returns hours for a gap between 1 and 24 hours', () => {
|
||||
const from = new Date('2026-04-20T09:00:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('3');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Stunde/i);
|
||||
});
|
||||
|
||||
it('returns days for a gap of 24 hours or more', () => {
|
||||
const from = new Date('2026-04-18T12:00:00Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
expect(relativeTimeDe(from, NOW)).toMatch(/Tag/i);
|
||||
});
|
||||
|
||||
it('rounds minutes to the nearest whole number', () => {
|
||||
const from = new Date('2026-04-20T11:58:20Z');
|
||||
expect(relativeTimeDe(from, NOW)).toContain('2');
|
||||
});
|
||||
|
||||
it('handles zero gap as 0 minutes', () => {
|
||||
expect(relativeTimeDe(NOW, NOW)).toMatch(/0/);
|
||||
expect(relativeTimeDe(NOW, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
|
||||
it('falls back to 0 minutes when the input Date is invalid', () => {
|
||||
const invalid = new Date('not-a-real-date');
|
||||
// Never crash the UI if the backend ever ships a malformed uploadedAt.
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/0/);
|
||||
expect(relativeTimeDe(invalid, NOW)).toMatch(/Minute/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('relativeYearsDe', () => {
|
||||
it('returns singular "vor 1 Jahr" for exactly one whole year ago', () => {
|
||||
const from = new Date('2025-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns plural "vor N Jahren" for more than one year', () => {
|
||||
const from = new Date('1940-04-20T12:00:00Z');
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor 86 Jahren');
|
||||
});
|
||||
|
||||
it('floors a partial year down (eleven months ago = 0 years)', () => {
|
||||
const from = new Date('2025-06-01T00:00:00Z');
|
||||
// We show "vor weniger als 1 Jahr" rather than rounding up to 1.
|
||||
expect(relativeYearsDe(from, NOW)).toBe('vor weniger als 1 Jahr');
|
||||
});
|
||||
|
||||
it('returns empty string when the input Date is invalid', () => {
|
||||
const invalid = new Date('not-a-real-date');
|
||||
expect(relativeYearsDe(invalid, NOW)).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for future dates', () => {
|
||||
const future = new Date('2030-01-01T00:00:00Z');
|
||||
expect(relativeYearsDe(future, NOW)).toBe('');
|
||||
});
|
||||
});
|
||||
30
frontend/src/lib/shared/relativeTime.ts
Normal file
30
frontend/src/lib/shared/relativeTime.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
export function relativeTimeDe(from: Date, now: Date = new Date()): string {
|
||||
const minutes = Math.round((now.getTime() - from.getTime()) / 60_000);
|
||||
// Malformed input (e.g. Invalid Date from a broken backend string) must not
|
||||
// crash the dashboard — fall back to "0 Minuten" rather than render NaN.
|
||||
if (!Number.isFinite(minutes)) return m.comment_time_minutes({ count: 0 });
|
||||
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
|
||||
if (minutes < 1440) return m.comment_time_hours({ count: Math.round(minutes / 60) });
|
||||
return m.comment_time_days({ count: Math.round(minutes / 1440) });
|
||||
}
|
||||
|
||||
// "vor N Jahren" for a historical letter date relative to now. Computed from
|
||||
// calendar fields (not a constant ms-per-year) so that a letter from exactly
|
||||
// one year ago reports "vor 1 Jahr" rather than falling on the wrong side of
|
||||
// a leap-year rounding. Returns "" for invalid or future dates — the caller
|
||||
// should then hide the relative-time label rather than render a misleading
|
||||
// "vor 0 Jahren".
|
||||
export function relativeYearsDe(from: Date, now: Date = new Date()): string {
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(now.getTime())) return '';
|
||||
if (from.getTime() > now.getTime()) return '';
|
||||
let years = now.getUTCFullYear() - from.getUTCFullYear();
|
||||
const beforeAnniversary =
|
||||
now.getUTCMonth() < from.getUTCMonth() ||
|
||||
(now.getUTCMonth() === from.getUTCMonth() && now.getUTCDate() < from.getUTCDate());
|
||||
if (beforeAnniversary) years -= 1;
|
||||
if (years < 1) return 'vor weniger als 1 Jahr';
|
||||
if (years === 1) return 'vor 1 Jahr';
|
||||
return `vor ${years} Jahren`;
|
||||
}
|
||||
71
frontend/src/lib/shared/types.ts
Normal file
71
frontend/src/lib/shared/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type MentionDTO = {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
};
|
||||
|
||||
export type CommentReply = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
export type FlatMessage = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
export type Comment = {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
replies: CommentReply[];
|
||||
mentionDTOs?: MentionDTO[];
|
||||
};
|
||||
|
||||
export type DocumentPanelTab = 'metadata' | 'transcription' | 'discussion' | 'history';
|
||||
|
||||
export type PersonMention = {
|
||||
personId: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
export type TranscriptionBlockData = {
|
||||
id: string;
|
||||
annotationId: string;
|
||||
documentId: string;
|
||||
text: string;
|
||||
label: string | null;
|
||||
sortOrder: number;
|
||||
version: number;
|
||||
source: 'MANUAL' | 'OCR';
|
||||
reviewed: boolean;
|
||||
mentionedPersons: PersonMention[];
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
export type Annotation = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
pageNumber: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
color: string;
|
||||
createdAt: string;
|
||||
fileHash?: string | null;
|
||||
polygon?: [number, number][] | null;
|
||||
};
|
||||
80
frontend/src/lib/shared/utils.spec.ts
Normal file
80
frontend/src/lib/shared/utils.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { germanToIso, isoToGerman } from './utils';
|
||||
|
||||
describe('isoToGerman', () => {
|
||||
it('converts a standard ISO date', () => {
|
||||
expect(isoToGerman('2024-03-15')).toBe('15.03.2024');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(isoToGerman('2024-01-05')).toBe('05.01.2024');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(isoToGerman('1945-12-31')).toBe('31.12.1945');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(isoToGerman('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(isoToGerman('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial ISO string', () => {
|
||||
expect(isoToGerman('2024-03')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO with time component', () => {
|
||||
expect(isoToGerman('2024-03-15T12:00:00')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('germanToIso', () => {
|
||||
it('converts a standard German date', () => {
|
||||
expect(germanToIso('15.03.2024')).toBe('2024-03-15');
|
||||
});
|
||||
|
||||
it('preserves leading zeros for day and month', () => {
|
||||
expect(germanToIso('05.01.2024')).toBe('2024-01-05');
|
||||
});
|
||||
|
||||
it('handles December 31', () => {
|
||||
expect(germanToIso('31.12.1945')).toBe('1945-12-31');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(germanToIso('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for plain text', () => {
|
||||
expect(germanToIso('not-a-date')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for date without leading zeros', () => {
|
||||
expect(germanToIso('5.3.2024')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for ISO format input', () => {
|
||||
expect(germanToIso('2024-03-15')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty string for partial German date', () => {
|
||||
expect(germanToIso('15.03')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('round-trip conversion', () => {
|
||||
const dates = ['2024-03-15', '1945-01-01', '2000-12-31', '1899-07-04'];
|
||||
|
||||
for (const date of dates) {
|
||||
it(`ISO → German → ISO is identity for ${date}`, () => {
|
||||
expect(germanToIso(isoToGerman(date))).toBe(date);
|
||||
});
|
||||
}
|
||||
|
||||
it('German → ISO → German is identity', () => {
|
||||
expect(isoToGerman(germanToIso('20.04.1889'))).toBe('20.04.1889');
|
||||
});
|
||||
});
|
||||
1
frontend/src/lib/shared/utils.ts
Normal file
1
frontend/src/lib/shared/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { isoToGerman, germanToIso } from '$lib/shared/utils/date';
|
||||
Reference in New Issue
Block a user