refactor: move shared utilities to lib/shared/ sub-packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-05 14:35:15 +02:00
parent 7cb922e90f
commit d6db7a07bd
117 changed files with 97 additions and 97 deletions

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { bucketByDay } from './date-buckets';
function date(iso: string): Date {
return new Date(iso);
}
describe('bucketByDay', () => {
// Wednesday 2026-04-22 at 12:00 Berlin. Week start (Mon) = 2026-04-20.
const now = date('2026-04-22T12:00:00+02:00');
it('returns "today" for a time earlier today', () => {
expect(bucketByDay(date('2026-04-22T06:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "today" at exact midnight start of today', () => {
expect(bucketByDay(date('2026-04-22T00:00:00+02:00'), now, 'de-DE')).toBe('today');
});
it('returns "yesterday" for any time on the previous day', () => {
expect(bucketByDay(date('2026-04-21T23:59:59+02:00'), now, 'de-DE')).toBe('yesterday');
expect(bucketByDay(date('2026-04-21T00:00:00+02:00'), now, 'de-DE')).toBe('yesterday');
});
it('returns "thisWeek" for the Monday that starts this week (Monday-anchored, de-DE)', () => {
expect(bucketByDay(date('2026-04-20T10:00:00+02:00'), now, 'de-DE')).toBe('thisWeek');
});
it('returns "older" for anything before the start of this week (de-DE)', () => {
expect(bucketByDay(date('2026-04-19T23:00:00+02:00'), now, 'de-DE')).toBe('older');
expect(bucketByDay(date('2026-04-13T10:00:00+02:00'), now, 'de-DE')).toBe('older');
});
it('uses Sunday-start week for en-US', () => {
const sundayRef = date('2026-04-19T12:00:00+02:00');
expect(bucketByDay(date('2026-04-19T06:00:00+02:00'), sundayRef, 'en-US')).toBe('today');
expect(
bucketByDay(date('2026-04-13T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('thisWeek');
expect(
bucketByDay(date('2026-04-11T10:00:00+02:00'), date('2026-04-18T12:00:00+02:00'), 'en-US')
).toBe('older');
});
it('handles DST spring-forward correctly (Europe/Berlin 2026-03-29)', () => {
const justAfterDst = date('2026-03-29T03:15:00+02:00');
const sameDay = date('2026-03-29T10:00:00+02:00');
expect(bucketByDay(justAfterDst, sameDay, 'de-DE')).toBe('today');
});
});

View File

@@ -0,0 +1,35 @@
export type DayBucket = 'today' | 'yesterday' | 'thisWeek' | 'older';
const DAY_MS = 24 * 60 * 60 * 1000;
const SUNDAY_START_LOCALES = new Set(['en-us', 'en-ca', 'en-ph', 'ja-jp', 'he-il', 'pt-br']);
function weekStartDay(locale?: string): 0 | 1 {
if (!locale) return 1;
return SUNDAY_START_LOCALES.has(locale.toLowerCase()) ? 0 : 1;
}
function startOfDay(d: Date): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function startOfWeek(d: Date, firstDay: 0 | 1): Date {
const x = startOfDay(d);
const diff = (x.getDay() - firstDay + 7) % 7;
x.setDate(x.getDate() - diff);
return x;
}
export function bucketByDay(date: Date, now: Date = new Date(), locale?: string): DayBucket {
const today = startOfDay(now);
const target = startOfDay(date);
if (target.getTime() === today.getTime()) return 'today';
if (today.getTime() - target.getTime() <= DAY_MS) return 'yesterday';
const weekStart = startOfWeek(today, weekStartDay(locale));
if (target.getTime() >= weekStart.getTime()) return 'thisWeek';
return 'older';
}

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { formatDate, formatGermanDateInput, isoToGerman, germanToIso } from './date';
// ─── formatDate ──────────────────────────────────────────────────────────────
describe('formatDate', () => {
it('defaults to long format when no format arg is passed', () => {
expect(formatDate('1943-12-24')).toBe('24. Dezember 1943');
});
it('formats long date with German month name', () => {
expect(formatDate('1943-12-24', 'long')).toBe('24. Dezember 1943');
});
it('formats short date as dd.mm.yyyy', () => {
expect(formatDate('1943-12-24', 'short')).toBe('24.12.1943');
});
it('does not shift Dec 31 to Jan 1 (T12:00:00 UTC guard)', () => {
expect(formatDate('1943-12-31', 'short')).toBe('31.12.1943');
});
});
// ─── isoToGerman ─────────────────────────────────────────────────────────────
describe('isoToGerman', () => {
it('converts a valid ISO date to DD.MM.YYYY', () => {
expect(isoToGerman('2024-12-20')).toBe('20.12.2024');
});
it('returns empty string for empty input', () => {
expect(isoToGerman('')).toBe('');
});
it('returns empty string for invalid format', () => {
expect(isoToGerman('not-a-date')).toBe('');
});
});
// ─── germanToIso ─────────────────────────────────────────────────────────────
describe('germanToIso', () => {
it('converts DD.MM.YYYY to ISO', () => {
expect(germanToIso('20.12.2024')).toBe('2024-12-20');
});
it('returns empty string for partial input', () => {
expect(germanToIso('20.12')).toBe('');
});
it('returns empty string for empty input', () => {
expect(germanToIso('')).toBe('');
});
});
// ─── formatGermanDateInput ────────────────────────────────────────────────────
describe('formatGermanDateInput digit stream (no dots typed)', () => {
it('leaves 12 digits as-is', () => {
expect(formatGermanDateInput('2')).toBe('2');
expect(formatGermanDateInput('20')).toBe('20');
});
it('auto-inserts dot after 2 digits for 34 digit input', () => {
expect(formatGermanDateInput('201')).toBe('20.1');
expect(formatGermanDateInput('2012')).toBe('20.12');
});
it('auto-inserts two dots for 58 digit input', () => {
expect(formatGermanDateInput('20121')).toBe('20.12.1');
expect(formatGermanDateInput('20122024')).toBe('20.12.2024');
});
it('ignores digits beyond 8', () => {
expect(formatGermanDateInput('201220249')).toBe('20.12.2024');
});
});
describe('formatGermanDateInput manual dot entry with padding', () => {
it('pads single-digit day to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('3.')).toBe('03.');
});
it('does not pad a 2-digit day', () => {
expect(formatGermanDateInput('03.')).toBe('03.');
expect(formatGermanDateInput('20.')).toBe('20.');
});
it('pads single-digit month to 2 digits when dot is typed after it', () => {
expect(formatGermanDateInput('03.3.')).toBe('03.03.');
});
it('does not pad a 2-digit month', () => {
expect(formatGermanDateInput('03.12.')).toBe('03.12.');
});
it('pads both day and month in a fully typed date', () => {
expect(formatGermanDateInput('3.3.2012')).toBe('03.03.2012');
});
it('pads only day when month is already 2 digits', () => {
expect(formatGermanDateInput('3.12.2024')).toBe('03.12.2024');
});
it('pads only month when day is already 2 digits', () => {
expect(formatGermanDateInput('20.3.2024')).toBe('20.03.2024');
});
it('handles a complete date entered with manual dots and no padding needed', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
it('overflows excess day digits into month when dot follows', () => {
expect(formatGermanDateInput('123.')).toBe('12.3');
});
it('caps year digits at 4', () => {
expect(formatGermanDateInput('03.03.20249')).toBe('03.03.2024');
});
it('overflows excess month digits into year (digit stream then continue typing)', () => {
// User typed digits → auto-dot gave "20.12", then types "2" → raw becomes "20.122"
expect(formatGermanDateInput('20.122')).toBe('20.12.2');
});
it('continues building year after overflow', () => {
expect(formatGermanDateInput('20.12.2024')).toBe('20.12.2024');
});
});

View File

@@ -0,0 +1,123 @@
/**
* Format an ISO date string (YYYY-MM-DD) for display.
* Uses T12:00:00 to avoid UTC timezone off-by-one when converting to local time.
* Defaults to 'long' (e.g. "24. Dezember 1943"); pass 'short' for DD.MM.YYYY.
*/
export function formatDate(isoDate: string, format: 'short' | 'long' = '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);
}
/**
* Format an ISO date string for medium-length display (e.g. "15. Jun. 1920").
* Uses T12:00:00 to avoid UTC timezone off-by-one.
* Pass an explicit BCP 47 locale tag to respect the app locale; defaults to 'de-DE'.
*/
export function formatMCDate(isoDate: string, locale: string = 'de-DE'): string {
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'short',
year: 'numeric'
}).format(new Date(isoDate + 'T12:00:00'));
}
/**
* Converts an ISO date string (YYYY-MM-DD) to German display format (DD.MM.YYYY).
* Returns an empty string for invalid or empty input.
*/
export function isoToGerman(iso: string): string {
if (!iso || !/^\d{4}-\d{2}-\d{2}$/.test(iso)) return '';
const [y, m, d] = iso.split('-');
return `${d}.${m}.${y}`;
}
/**
* Converts a German date string (DD.MM.YYYY) to ISO format (YYYY-MM-DD).
* Returns an empty string for invalid or empty input.
*/
export function germanToIso(german: string): string {
const match = german.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return '';
const [, d, m, y] = match;
return `${y}-${m}-${d}`;
}
/**
* Formats a raw date string into German DD.MM.YYYY format.
*
* Handles two modes:
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
* and overflows extra digits from day→month and month→year
*/
export function formatGermanDateInput(raw: string): string {
if (!raw.includes('.')) {
const digits = raw.replace(/\D/g, '').slice(0, 8);
if (digits.length <= 2) return digits;
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
}
const trailingDot = raw.endsWith('.');
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
let day = parts[0] ?? '';
let month = parts[1] ?? '';
let year = parts[2] ?? '';
let dayOverflowed = false;
if (day.length > 2) {
month = day.slice(2) + month;
day = day.slice(0, 2);
dayOverflowed = true;
}
let monthOverflowed = false;
if (month.length > 2) {
year = month.slice(2) + year;
month = month.slice(0, 2);
monthOverflowed = true;
}
year = year.slice(0, 4);
const afterDay = !dayOverflowed && parts.length >= 2;
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
day = '0' + day;
}
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
month = '0' + month;
}
if (year) return `${day}.${month}.${year}`;
if (month) {
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
return `${day}.${month}${dot2}`;
}
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
return `${day}${dot1}`;
}
/**
* Handles a date input event for German-format date fields (DD.MM.YYYY).
* Strips non-digits, formats with dots, mutates the input's displayed value,
* and returns the display string and its ISO equivalent.
*/
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
const input = e.target as HTMLInputElement;
const display = formatGermanDateInput(input.value);
input.value = display;
return { display, iso: germanToIso(display) };
}

View File

@@ -0,0 +1,69 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('does not fire before the delay has elapsed', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(199);
expect(fn).not.toHaveBeenCalled();
});
it('fires exactly once after the delay', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
it('resets the timer on each call — fires only once after inactivity', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(100);
debounced();
vi.advanceTimersByTime(100);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(1);
});
it('passes the latest arguments to the callback', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced('first');
debounced('second');
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledWith('second');
});
it('can fire again after the first invocation settles', () => {
const fn = vi.fn();
const debounced = debounce(fn, 200);
debounced();
vi.advanceTimersByTime(200);
debounced();
vi.advanceTimersByTime(200);
expect(fn).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,12 @@
/**
* Returns a debounced version of fn that delays invocation until after
* `delay` ms have elapsed since the last call.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function debounce<T extends (...args: any[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>;
return ((...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
}) as T;
}

View File

@@ -0,0 +1,152 @@
import { describe, expect, it, vi } from 'vitest';
import { scrollToCommentFromQuery, type DeepLinkScrollOptions } from './deepLinkScroll';
const COMMENT_ID = 'cccc1111-1111-1111-1111-111111111111';
const ANNOTATION_ID = 'aaaa2222-2222-2222-2222-222222222222';
function fakeElement() {
return {
scrollIntoView: vi.fn(),
focus: vi.fn()
} as unknown as HTMLElement;
}
type Overrides = Partial<DeepLinkScrollOptions>;
function buildOpts(overrides: Overrides = {}): DeepLinkScrollOptions {
const el = overrides.getElement ? null : fakeElement();
return {
transcribeMode: true,
setTranscribeMode: vi.fn(),
setPanelMode: vi.fn(),
loadBlocks: vi.fn().mockResolvedValue(undefined),
setActiveAnnotationId: vi.fn(),
flashAnnotation: vi.fn(),
prefersReducedMotion: false,
afterTick: vi.fn().mockResolvedValue(undefined),
getElement: vi.fn().mockReturnValue(el),
onStripUrl: vi.fn(),
...overrides
};
}
describe('scrollToCommentFromQuery', () => {
it('is a no-op when commentId query param is absent', async () => {
const url = new URL('https://app/documents/doc-1');
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
expect(opts.onStripUrl).not.toHaveBeenCalled();
});
it('is a no-op when annotationId query param is absent even if commentId is present', async () => {
const url = new URL(`https://app/documents/doc-1?commentId=${COMMENT_ID}`);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setActiveAnnotationId).not.toHaveBeenCalled();
expect(opts.getElement).not.toHaveBeenCalled();
});
it('scrolls to the comment element and focuses it when both params are present', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.getElement).toHaveBeenCalledWith(`comment-${COMMENT_ID}`);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'center' });
expect(el.focus).toHaveBeenCalledWith({ preventScroll: true });
});
it('triggers the annotation flash after scrolling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(el) });
await scrollToCommentFromQuery(url, opts);
expect(opts.flashAnnotation).toHaveBeenCalledWith(ANNOTATION_ID);
});
it('enters transcribe mode and awaits loadBlocks when transcribe mode is off', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ transcribeMode: false });
await scrollToCommentFromQuery(url, opts);
expect(opts.setTranscribeMode).toHaveBeenCalledWith(true);
expect(opts.loadBlocks).toHaveBeenCalled();
});
it('is a graceful no-op when the target element is not in the DOM', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ getElement: vi.fn().mockReturnValue(null) });
// Must not throw. Flash should not fire — nothing to highlight.
await expect(scrollToCommentFromQuery(url, opts)).resolves.toBeUndefined();
expect(opts.flashAnnotation).not.toHaveBeenCalled();
});
it('uses behavior "instant" when prefers-reduced-motion is set', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const el = fakeElement();
const opts = buildOpts({
prefersReducedMotion: true,
getElement: vi.fn().mockReturnValue(el)
});
await scrollToCommentFromQuery(url, opts);
expect(el.scrollIntoView).toHaveBeenCalledWith({ behavior: 'instant', block: 'center' });
});
it('strips both commentId and annotationId from the URL after handling', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.onStripUrl).toHaveBeenCalled();
});
it('forces panel mode to "edit" so the comment DOM exists on reviewed documents', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts();
await scrollToCommentFromQuery(url, opts);
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
});
it('forces panel mode to "edit" even when transcribe mode is already on', async () => {
const url = new URL(
`https://app/documents/doc-1?commentId=${COMMENT_ID}&annotationId=${ANNOTATION_ID}`
);
const opts = buildOpts({ transcribeMode: true });
await scrollToCommentFromQuery(url, opts);
expect(opts.setPanelMode).toHaveBeenCalledWith('edit');
});
});

View File

@@ -0,0 +1,46 @@
export type DeepLinkScrollOptions = {
transcribeMode: boolean;
setTranscribeMode: (value: boolean) => void;
setPanelMode: (mode: 'read' | 'edit') => void;
loadBlocks: () => Promise<void>;
setActiveAnnotationId: (id: string) => void;
flashAnnotation: (annotationId: string) => void;
prefersReducedMotion: boolean;
afterTick: () => Promise<void>;
getElement: (id: string) => HTMLElement | null;
onStripUrl: () => void;
};
export async function scrollToCommentFromQuery(
url: URL,
opts: DeepLinkScrollOptions
): Promise<void> {
const commentId = url.searchParams.get('commentId');
if (!commentId) return;
const annotationId = url.searchParams.get('annotationId');
if (!annotationId) return;
if (!opts.transcribeMode) {
opts.setTranscribeMode(true);
await opts.loadBlocks();
}
// Comments only render in edit mode — force it so the deep-link target
// exists in the DOM even if the document already has reviewed transcriptions
// (which default the panel to read mode).
opts.setPanelMode('edit');
opts.setActiveAnnotationId(annotationId);
await opts.afterTick();
const el = opts.getElement(`comment-${commentId}`);
if (el) {
const behavior: ScrollBehavior = opts.prefersReducedMotion ? 'instant' : 'smooth';
el.scrollIntoView({ behavior, block: 'center' });
el.focus({ preventScroll: true });
opts.flashAnnotation(annotationId);
}
opts.onStripUrl();
}

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { extractText, plainExcerpt } from './extractText';
describe('extractText', () => {
it('returns empty string for null/undefined/empty', () => {
expect(extractText(null)).toBe('');
expect(extractText(undefined)).toBe('');
expect(extractText('')).toBe('');
});
it('strips tags and preserves visible text', () => {
expect(extractText('<p>Hello <strong>world</strong></p>')).toBe('Hello world');
});
it('collapses whitespace within and between blocks', () => {
expect(extractText('<p>One</p><p>Two</p>')).toBe('OneTwo');
expect(extractText('<p>foo bar</p>')).toBe('foo bar');
});
// XSS-shaped inputs: extractText must NOT execute, render, or expose the
// payload as HTML. It is only required to return *some* string. The fact
// that it exists is documented as a non-sanitiser; these tests prevent
// silent regressions where the function might somehow leak a tag.
describe('XSS-shaped input — never re-emits markup, even though this is not a sanitiser', () => {
it('drops <script> and surfaces only its text content', () => {
const out = extractText('<p>ok</p><script>alert(1)</script>');
expect(out).not.toContain('<script>');
expect(out).not.toContain('</script>');
});
it('drops <svg/onload> markup', () => {
const out = extractText('<svg/onload=alert(1)>');
expect(out).not.toContain('<svg');
expect(out).not.toContain('onload');
});
it('drops <iframe srcdoc=…> markup', () => {
const out = extractText('<iframe srcdoc="<script>alert(1)</script>">');
expect(out).not.toContain('<iframe');
expect(out).not.toContain('srcdoc');
});
it('drops <a href="javascript:…"> tag (text content may remain)', () => {
const out = extractText('<a href="javascript:alert(1)">click</a>');
expect(out).not.toContain('<a ');
expect(out).not.toContain('javascript:');
});
});
});
describe('plainExcerpt', () => {
it('returns full text when under the limit', () => {
expect(plainExcerpt('<p>short</p>', 80)).toBe('short');
});
it('truncates at the boundary with an ellipsis', () => {
const html = '<p>' + 'a'.repeat(100) + '</p>';
const out = plainExcerpt(html, 20);
expect(out.length).toBeLessThanOrEqual(21);
expect(out.endsWith('…')).toBe(true);
});
it('breaks at a word boundary when possible', () => {
const out = plainExcerpt('<p>The quick brown fox jumps over</p>', 18);
expect(out).toBe('The quick brown…');
});
});

View File

@@ -0,0 +1,38 @@
/**
* **Not a sanitizer.** This module extracts visible text from a (presumed
* already-sanitised) HTML string for excerpt rendering. It is safe ONLY
* because the Geschichte body is sanitised against the OWASP allow-list
* on the server before persistence, and via DOMPurify on render.
*
* Do not use these helpers to defend against XSS — `safeHtml()` in
* `./sanitize.ts` is the only sanitiser. Calling `extractText()` on
* untrusted input that has not been sanitised does not protect against
* `javascript:` URLs, event-handler attributes, or `<svg/onload>` payloads.
*/
/**
* Strip tags and return plain text. Uses DOMParser in the browser; on the
* server it falls back to a regex that drops angle-bracket sequences.
* The fallback is **not** a sanitiser — see module docstring.
*/
export function extractText(html: string | null | undefined): string {
if (!html) return '';
if (typeof DOMParser === 'function') {
const doc = new DOMParser().parseFromString(html, 'text/html');
return (doc.body.textContent ?? '').replace(/\s+/g, ' ').trim();
}
return html
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Strip tags then truncate to `max` chars on a word boundary, appending an
* ellipsis when truncated. Used for editorial story excerpts.
*/
export function plainExcerpt(html: string | null | undefined, max = 80): string {
const text = extractText(html);
if (text.length <= max) return text;
return text.slice(0, max).replace(/\s+\S*$/, '') + '…';
}

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import {
computeHoverCardPosition,
CARD_WIDTH_PX,
CARD_HEIGHT_PX,
CARD_GAP_PX,
BOTTOM_BAND_RATIO,
RIGHT_FLIP_THRESHOLD_PX
} from './hoverCardPosition';
const makeRect = (overrides: Partial<DOMRect> = {}): DOMRect => {
const base = { top: 100, left: 200, bottom: 120, right: 300, width: 100, height: 20 };
const merged = { ...base, ...overrides };
return {
...merged,
x: merged.left,
y: merged.top,
toJSON: () => merged
} as DOMRect;
};
const vp = { viewportWidth: 1440, viewportHeight: 900 };
describe('computeHoverCardPosition', () => {
it('exports the spec constants used by the spec/CSS layer', () => {
// Pin the values the design spec calls out — if these drift, the design spec
// in #5329 needs to drift with them. Felix's PR review #2 (named constants).
expect(CARD_WIDTH_PX).toBe(320);
expect(CARD_HEIGHT_PX).toBe(180);
expect(CARD_GAP_PX).toBe(6);
expect(BOTTOM_BAND_RATIO).toBe(0.7);
expect(RIGHT_FLIP_THRESHOLD_PX).toBe(300);
});
describe('default placement (below-right)', () => {
it('positions the card below the rect with a small gap', () => {
const rect = makeRect({ top: 100, bottom: 120, left: 200 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(120 + CARD_GAP_PX);
expect(result.left).toBe(200);
});
});
describe('flip-up rule (Leonie #5329)', () => {
it('flips up when the card would overflow the bottom edge', () => {
// Mention sits 50px above the viewport bottom — card is 180px tall, can't fit below
const rect = makeRect({ top: 800, bottom: 850 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(800 - CARD_HEIGHT_PX - CARD_GAP_PX);
});
it('flips up when the mention sits in the bottom 30% of the viewport (BOTTOM_BAND_RATIO)', () => {
// rect.top is at 80% of viewport — fits below numerically, but poor UX
const rect = makeRect({ top: 720, bottom: 740 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(720 - CARD_HEIGHT_PX - CARD_GAP_PX);
});
});
describe('flip-left rule', () => {
it('flips left when the rect is within RIGHT_FLIP_THRESHOLD_PX of the right edge', () => {
// vw - rect.left = 1440 - 1200 = 240 < 300, so flip
const rect = makeRect({ left: 1200, right: 1300, top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, { viewportWidth: 1440, viewportHeight: 900 });
// left = right - CARD_WIDTH = 1300 - 320 = 980
expect(result.left).toBe(980);
});
it('does not flip left when the rect has plenty of right-side room', () => {
// vw - rect.left = 1440 - 200 = 1240 >> 300 → no flip
const rect = makeRect({ left: 200, right: 300 });
const result = computeHoverCardPosition(rect, vp);
expect(result.left).toBe(200);
});
});
describe('viewport clamping (Leonie FINDING-05)', () => {
it('clamps left so the card never overflows the right edge', () => {
// On a 320px viewport, even with flip the card width equals the viewport.
// Without clamping the card would be at left=0 but extend to 320 — fine.
// At viewport=400px with rect.left=200, flip puts left=300-320=-20, clamped to 0.
const rect = makeRect({ left: 200, right: 300, top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, { viewportWidth: 400, viewportHeight: 900 });
expect(result.left).toBeGreaterThanOrEqual(0);
expect(result.left + CARD_WIDTH_PX).toBeLessThanOrEqual(400);
});
it('never returns a negative top or left', () => {
const rect = makeRect({ top: -50, left: -100, bottom: -30, right: 0 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBeGreaterThanOrEqual(0);
expect(result.left).toBeGreaterThanOrEqual(0);
});
});
describe('position: fixed (viewport-relative coordinates)', () => {
it('returns viewport-relative top — does not add scroll offset', () => {
// getBoundingClientRect values are already viewport-relative; with position:fixed
// we use them directly without adding scrollY.
const rect = makeRect({ top: 100, bottom: 120 });
const result = computeHoverCardPosition(rect, vp);
expect(result.top).toBe(120 + CARD_GAP_PX);
});
it('returns viewport-relative left — does not add scroll offset', () => {
const rect = makeRect({ top: 100, bottom: 120, left: 200, right: 300 });
const result = computeHoverCardPosition(rect, vp);
expect(result.left).toBe(200);
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* Pure positioning logic for the person-mention hover card.
*
* Pulled out of TranscriptionReadView so the four placement branches
* (default, flip-up, flip-left, both) plus the viewport clamp are unit-testable
* without DOM. Sara's PR-B2 review #6 (no test for computeCardPosition) and
* Leonie's FINDING-05 (320px overflow) both land here.
*/
/** Width of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_WIDTH_PX = 320;
/** Min-height of the rendered hover card. Mirrored in PersonHoverCard.svelte's CSS. */
export const CARD_HEIGHT_PX = 180;
/** Gap between the mention rect and the card so they do not touch. */
export const CARD_GAP_PX = 6;
/**
* Mentions in the bottom 30% of the viewport flip the card up by default,
* even if it would numerically fit below — keeping the eye-line stable
* is more important than minimal travel (Leonie #5329).
*/
export const BOTTOM_BAND_RATIO = 0.7;
/**
* Mentions within this distance of the right viewport edge flip the card
* left so it stays fully visible.
*/
export const RIGHT_FLIP_THRESHOLD_PX = 300;
export type Viewport = {
viewportWidth: number;
viewportHeight: number;
};
export type CardPosition = { top: number; left: number };
/**
* Compute absolute-positioned top/left for the hover card, given a rect for
* the mention anchor and the current viewport. Output is in document
* coordinates (already includes scroll offsets).
*/
export function computeHoverCardPosition(rect: DOMRect, vp: Viewport): CardPosition {
let top = rect.bottom + CARD_GAP_PX;
let left = rect.left;
const overflowsBottom = vp.viewportHeight - rect.bottom < CARD_HEIGHT_PX + CARD_GAP_PX;
const inBottomBand = rect.top > vp.viewportHeight * BOTTOM_BAND_RATIO;
if (overflowsBottom || inBottomBand) {
top = rect.top - CARD_HEIGHT_PX - CARD_GAP_PX;
}
if (vp.viewportWidth - rect.left < RIGHT_FLIP_THRESHOLD_PX) {
left = rect.right - CARD_WIDTH_PX;
}
// Clamp left so the card never extends past the right viewport edge
// (FINDING-05: at 320px viewport the flip would otherwise produce a
// negative left or right-side overflow).
left = Math.min(left, vp.viewportWidth - CARD_WIDTH_PX - CARD_GAP_PX);
return {
top: Math.max(0, top),
left: Math.max(0, left)
};
}

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { countRequiredFilled } from './requiredFields';
describe('countRequiredFilled', () => {
it('returns 0 when all three fields are empty', () => {
expect(countRequiredFilled('', '', '')).toBe(0);
});
it('returns 1 when only title is set', () => {
expect(countRequiredFilled('Ein Brief', '', '')).toBe(1);
});
it('returns 1 when only dateIso is set', () => {
expect(countRequiredFilled('', '1920-05-01', '')).toBe(1);
});
it('returns 1 when only senderId is set', () => {
expect(countRequiredFilled('', '', 'person-uuid')).toBe(1);
});
it('returns 2 when title and dateIso are set', () => {
expect(countRequiredFilled('Ein Brief', '1920-05-01', '')).toBe(2);
});
it('returns 2 when title and senderId are set', () => {
expect(countRequiredFilled('Ein Brief', '', 'person-uuid')).toBe(2);
});
it('returns 2 when dateIso and senderId are set', () => {
expect(countRequiredFilled('', '1920-05-01', 'person-uuid')).toBe(2);
});
it('returns 3 when all three fields are set', () => {
expect(countRequiredFilled('Ein Brief', '1920-05-01', 'person-uuid')).toBe(3);
});
});

View File

@@ -0,0 +1,3 @@
export function countRequiredFilled(title: string, dateIso: string, senderId: string): number {
return [title, dateIso, senderId].filter(Boolean).length;
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { safeHtml } from './sanitize';
describe('safeHtml', () => {
it('returns empty string for null/undefined/empty input', () => {
expect(safeHtml(null)).toBe('');
expect(safeHtml(undefined)).toBe('');
expect(safeHtml('')).toBe('');
});
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
const html =
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
'<h2>H2</h2><h3>H3</h3>' +
'<ul><li>a</li></ul><ol><li>b</li></ol>';
const result = safeHtml(html);
expect(result).toContain('<strong>bold</strong>');
expect(result).toContain('<em>italic</em>');
expect(result).toContain('<br>');
expect(result).toContain('<h2>H2</h2>');
expect(result).toContain('<h3>H3</h3>');
expect(result).toContain('<ul>');
expect(result).toContain('<ol>');
expect(result).toContain('<li>a</li>');
});
it('strips <script> tags entirely', () => {
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('alert');
expect(result).toContain('<p>ok</p>');
});
it('strips on* event-handler attributes', () => {
const result = safeHtml('<p onclick="evil()">x</p>');
expect(result).not.toContain('onclick');
});
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
const result = safeHtml(
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
);
expect(result).not.toContain('<img');
expect(result).not.toContain('<a ');
expect(result).not.toContain('<iframe');
});
});

View File

@@ -0,0 +1,17 @@
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
/**
* Render-side sanitiser for Geschichte body HTML. The backend already
* sanitises with the OWASP allow-list on save, but we re-run on render
* because the API can be called directly and stored content can pre-date
* a tightening of the allow-list.
*/
export function safeHtml(raw: string | null | undefined): string {
if (!raw) return '';
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR: []
});
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import { sortDocumentsByDate } from './sort';
const doc = (id: string, documentDate: string | null) =>
({ id, documentDate }) as { id: string; documentDate: string | null };
describe('sortDocumentsByDate', () => {
it('sorts DESC by default — newest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result.map((d) => d.id)).toEqual(['b', 'c', 'a']);
});
it('sorts ASC — oldest first', () => {
const docs = [doc('a', '1920-01-01'), doc('b', '1950-06-15'), doc('c', '1935-03-10')];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result.map((d) => d.id)).toEqual(['a', 'c', 'b']);
});
it('places documents without a date last in DESC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'DESC');
expect(result[0].id).toBe('b');
expect(result.slice(1).map((d) => d.id)).toContain('a');
expect(result.slice(1).map((d) => d.id)).toContain('c');
});
it('places documents without a date last in ASC', () => {
const docs = [doc('a', null), doc('b', '1940-01-01'), doc('c', null)];
const result = sortDocumentsByDate(docs, 'ASC');
expect(result[0].id).toBe('b');
});
it('does not mutate the original array', () => {
const docs = [doc('a', '1950-01-01'), doc('b', '1920-01-01')];
const original = [...docs];
sortDocumentsByDate(docs, 'ASC');
expect(docs).toEqual(original);
});
it('returns an empty array unchanged', () => {
expect(sortDocumentsByDate([], 'DESC')).toEqual([]);
});
});

View File

@@ -0,0 +1,19 @@
export type SortDir = 'ASC' | 'DESC';
/**
* Returns a new array of documents sorted by documentDate.
* Documents without a date are always placed last, regardless of direction.
*/
export function sortDocumentsByDate<T extends { documentDate?: string | null }>(
docs: T[],
dir: SortDir
): T[] {
return [...docs].sort((a, b) => {
const da = a.documentDate ?? '';
const db = b.documentDate ?? '';
if (!da && !db) return 0;
if (!da) return 1;
if (!db) return -1;
return dir === 'DESC' ? db.localeCompare(da) : da.localeCompare(db);
});
}

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { m } from '$lib/paraglide/messages.js';
const { relativeTime } = await import('./time');
function msAgo(ms: number, now: Date): string {
return new Date(now.getTime() - ms).toISOString();
}
describe('relativeTime', () => {
const now = new Date('2024-06-15T12:00:00.000Z');
it('returns "just now" for timestamps under 60 seconds ago', () => {
const ts = msAgo(30_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_just_now());
});
it('returns 1-minute label for exactly 1 minute ago', () => {
const ts = msAgo(60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 1 }));
});
it('returns 59-minute label for exactly 59 minutes ago', () => {
const ts = msAgo(59 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_minutes({ count: 59 }));
});
it('returns 1-hour label for exactly 1 hour ago', () => {
const ts = msAgo(60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 1 }));
});
it('returns 23-hour label for 23 hours ago', () => {
const ts = msAgo(23 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_hours({ count: 23 }));
});
it('returns 1-day label for exactly 24 hours ago', () => {
const ts = msAgo(24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 1 }));
});
it('returns 6-day label for 6 days ago', () => {
const ts = msAgo(6 * 24 * 60 * 60_000, now);
expect(relativeTime(ts, now)).toBe(m.comment_time_days({ count: 6 }));
});
it('defaults now to current time when omitted', () => {
const ts = new Date(Date.now() - 5 * 60_000).toISOString();
expect(relativeTime(ts)).toBeTruthy();
});
});

View File

@@ -0,0 +1,12 @@
import { m } from '$lib/paraglide/messages.js';
export function relativeTime(isoString: string, now: Date = new Date()): string {
const diff = now.getTime() - new Date(isoString).getTime();
const minutes = Math.floor(diff / 60_000);
if (minutes < 1) return m.comment_time_just_now();
if (minutes < 60) return m.comment_time_minutes({ count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.comment_time_hours({ count: hours });
const days = Math.floor(hours / 24);
return m.comment_time_days({ count: days });
}